fetch_util 0.5.0 → 0.5.1

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: 1ede7613063d932c011dcc3923412b3b41bfbdeadab49df501af635b0b7ffbd0
4
- data.tar.gz: 5bd369cd22d7ca45c63046f707b12f09ad817d3b71267b3f5ccfd83455fb2c81
3
+ metadata.gz: 6d9dd7f8e6ba30f2d7ad5c9818fc214add21a18baa69f0e1081fe124d7781c35
4
+ data.tar.gz: 22e9bbc8ad10f85745e2791f196ce2633f4b0a795c95ac94f867ef3696d2053f
5
5
  SHA512:
6
- metadata.gz: 5103c8780482705beb59a40e16f31e3635f0f6512dffe3522ef7996b79652a45e81e00c716f085b2bacf85685dcd61f717477310864e5459a895cc7b5e9312ca
7
- data.tar.gz: 9118e9c1d177fbaf92bb19fac644b37419dee8712cd839586a350fe1f09b4f3434f9334c20a694693c0a2f6af9e57cef1f9aa1f61a5017c91c6a13e3344d8932
6
+ metadata.gz: f0115bb99e4c882fd46a06ac57b8350aaa464e651b5f7fb3f38e869a25b48b6ddde29a96a1f0fa955805c3de5459f436cacea8180dbe8246c662b896c2a5c991
7
+ data.tar.gz: 1813278203b73f64ce2bb26e2936ee42374d7b72fc1365622cba926d736672ac078efc5f03e6972703d47616f531483d74ad4e75c444c6665bef246186798af1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.5.1 - 2026-07-15
6
+
7
+ ### Added
8
+
9
+ - Allow browser fetches to wait within the configured timeout for site-delivered Anubis JavaScript/WebAssembly proof-of-work to resolve, while retaining explicit interstitial results when it does not.
10
+ - Add Yahoo as a direct search source with source-specific organic-card parsing and safe result-wrapper decoding.
11
+
12
+ ### Fixed
13
+
14
+ - Retry eligible Yahoo transport, HTTP 429, and HTTP 5xx failures up to two times after the initial request under the existing search deadline.
15
+ - Preserve candidates when source relevance is uncertain, including valid later-ranked evidence, and normalize scoped, negated, snake_case, and camelCase query evidence without turning uncertainty into an empty query.
16
+ - Stabilize default quoted, delimiter-bearing, and scoped searches with a healthy Yahoo authority set while preserving explicit source unions and normal fallback aggregation.
17
+
18
+ ### Changed
19
+
20
+ - Define agent retrieval as the primary product contract while keeping custom solvers, CAPTCHA handling, access circumvention, and high-throughput scraping behavior out of scope.
21
+ - Clarify that direct HTTP search challenges are diagnosed rather than executed.
22
+ - Change the default direct search sources to Brave, Bing, and Yahoo.
23
+
5
24
  ## v0.5.0 - 2026-07-13
6
25
 
7
26
  ### Added
data/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # fetch_util
2
2
 
3
- Reliable browser-backed fetching for Ruby.
3
+ Reliable browser-backed fetching for agents and Ruby applications.
4
4
 
5
5
  `fetch_util` renders modern pages, inspects the live DOM, classifies page shape, and returns compact markdown plus structured metadata.
6
6
 
7
+ Its primary product contract is agent retrieval: use it to discover and fetch public web information, then inspect structured warnings when access is incomplete or ambiguous.
8
+
7
9
  It also provides a plain-Ruby regulatory inspector for machine-readable crawl, index, and text-and-data-mining signals such as `robots.txt`, `X-Robots-Tag`, robots meta tags, and TDM reservation metadata.
8
10
 
9
11
  It helps applications distinguish between content pages and access/interstitial states such as consent prompts, login-required pages, and challenge screens. When original content is not available, it returns a compact summary with warnings rather than pretending the page was extracted successfully.
@@ -12,11 +14,11 @@ It helps applications distinguish between content pages and access/interstitial
12
14
 
13
15
  The easiest way to explain `fetch_util` is in three steps:
14
16
 
15
- - `Render` - load the page in Chromium, inspect the rendered DOM, and read page metadata.
17
+ - `Render` - load the page in Chromium, inspect the rendered DOM, and read page metadata. If a site delivers a JavaScript or WebAssembly proof-of-work challenge, the normal browser session may execute it and preserve its resulting cookies while waiting within the configured browser timeout.
16
18
  - `Classify` - identify whether the page is an article, list/index, docs page, search result, or an interstitial/access-limited state.
17
19
  - `Shape` - return compact markdown, normalized URLs, and warning metadata so the result is usable by agents, LLM workflows, and ordinary Ruby applications.
18
20
 
19
- In short: `fetch_util` makes the web easier to build on.
21
+ This is bounded native browser execution, not a custom challenge solver. If the challenge does not resolve within the bound, the result remains an explicit interstitial with warnings.
20
22
 
21
23
  ## Installation
22
24
 
@@ -75,11 +77,15 @@ fetch_util regulatory https://example.com/article --sources=machine,human
75
77
 
76
78
  ### Search
77
79
 
78
- Search uses direct HTTP requests to the supported sources, in parallel, rather than the browser fetcher. The default sources are `brave` and `bing`; explicit `--source` values may be any of `brave`, `bing`, `duckduckgo`, `google`, or `ecosia`.
80
+ Search uses direct HTTP requests to the supported sources in parallel. The default sources are `brave`, `bing`, and `yahoo`; explicit `--source` values may be any of `brave`, `bing`, `duckduckgo`, `google`, `ecosia`, or `yahoo`.
81
+
82
+ Search always emits one JSON object. The normal payload is exactly `{ "query": ..., "results": [...] }`. Results are interleaved by source rank, deduplicated by normalized URL, and retain every eligible result unless an explicit `--limit N` is supplied. `--limit` is applied after aggregation; there is no default result cap. Known Bing, Google, DuckDuckGo, and Yahoo result wrappers are decoded before destination validation.
79
83
 
80
- Search always emits one JSON object. The normal payload is exactly `{ "query": ..., "results": [...] }`. Results are interleaved by source rank, deduplicated by normalized URL, and retain every eligible result unless an explicit `--limit N` is supplied. `--limit` is applied after aggregation; there is no default result cap. Known Bing, Google, and DuckDuckGo result wrappers are decoded before destination validation.
84
+ Each search has one finite deadline shared by its source requests and parsing. After the initial Yahoo request, Yahoo may retry generic transport failures reported as `failed`, HTTP 429, or HTTP 5xx responses up to two times within that same deadline. Direct HTTP search challenges are diagnosed, not executed or bypassed. A source can be `ok`, `empty`, or `failed`; finite reasons include `challenge`, `failed`, `host`, `http_status`, `parse`, `query_mismatch`, `redirect`, `size`, and `timeout`.
81
85
 
82
- Each search has one finite deadline shared by its source requests and parsing. Challenges are reported, not bypassed. A source can be `ok`, `empty`, or `failed`; failure reasons include `challenge`, `failed`, `host`, `http_status`, `parse`, `query_mismatch`, `redirect`, `size`, and `timeout`. Normal source failures do not discard healthy peer results. With `--verbose-search`, the payload additionally contains ordered finite source `diagnostics`, and each result contains ordered `sources` and per-source `ranks`.
86
+ `query_mismatch` is a candidate-bearing relevance warning, not a terminal query failure. The transport retains parsed candidates even when another source succeeds, so an uncertain lexical check cannot erase valid later-ranked evidence or force an empty query. Explicit source unions expose those retained candidates; the separate default structured-query source policy below may select Yahoo's URL set. Scoped operators and negated terms are excluded from lexical evidence, and snake_case/camelCase identifiers share token boundaries.
87
+
88
+ For default structured queries containing quoted phrases, delimiters, or scoped operators, a healthy Yahoo response defines the returned URL set; matching peer URLs retain their source provenance and ranks. Explicit `--source` selections preserve the requested source union, and normal aggregation remains in effect when Yahoo is unavailable or uncertain. This source policy applies no result-count cap: every eligible authority result is returned unless the caller supplies `--limit`. With `--verbose-search`, the payload additionally contains ordered finite source `diagnostics`, and each result contains ordered `sources` and per-source `ranks`.
83
89
 
84
90
  For agent discovery, use an explicit first-pass budget, choose only the best 1-3 direct result URLs, then fetch those destinations and inspect JSON `warnings`, `suspect`, and `content_type` when needed. Add `--verbose-search` when results are empty or suspicious, or when source health matters:
85
91
 
@@ -109,8 +115,8 @@ Useful result fields:
109
115
 
110
116
  ## Common Options
111
117
 
112
- - `timeout:` browser timeout in seconds
113
- - `wait:` additional settle delay after page load
118
+ - `timeout:` browser timeout in seconds; it is also the bounded observation budget for a delivered Anubis challenge
119
+ - `wait:` settle interval used by applicable post-load stabilization paths; it does not control the challenge-completion budget
114
120
  - `wait_for_idle:` wait for Ferrum network idle before extraction
115
121
  - `limit:` search-only explicit maximum result count; omitted by default, search returns every result in the fetched responses
116
122
  - `idle_duration:` idle duration passed to Ferrum when `wait_for_idle` is enabled
@@ -180,14 +186,16 @@ pp FetchUtil.regulatory(
180
186
 
181
187
  - Extracts articles, list/index pages, and search pages into compact markdown.
182
188
  - Uses page classification to select extraction logic appropriate to the rendered page type.
183
- - Detects consent prompts, login-required pages, and challenge/interstitial screens and reports them with concise summaries and warning tags.
189
+ - Detects consent prompts, login-required pages, and challenge/interstitial screens and reports them with concise summaries and warning tags. A delivered JavaScript/WebAssembly proof-of-work may complete in the normal browser session within `timeout`; unresolved challenges remain explicit interstitials.
184
190
  - Cleans up docs/reference pages aggressively enough for agent consumption.
185
191
  - Preserves `final_url`, `canonical_url`, and warning metadata so callers can reason about redirects, mismatches, and interstitials.
186
192
  - Extracts regulatory allow/disallow signals from `robots.txt`, page headers/meta tags, and TDM reservation metadata without caching raw page bodies.
187
193
 
188
194
  ## Compliance Boundaries
189
195
 
190
- `fetch_util` is for rendering and summarizing publicly delivered page output. It may identify consent prompts, login-required pages, and challenge/interstitial states and return warning metadata for them. It is not intended to bypass account requirements, paywalls, verification systems, or other access controls.
196
+ `fetch_util` is primarily an agent retrieval tool for rendering and summarizing publicly delivered page output. Browser fetch may allow a site's delivered JavaScript/WebAssembly proof-of-work, such as Anubis, to complete within the configured browser timeout and normal cookie session. This is bounded native browser execution, not a custom solver.
197
+
198
+ Unresolved challenges remain explicit interstitials. CAPTCHA solving, account or paywall circumvention, identity deception, and high-throughput scraping optimization are out of scope. Direct HTTP search challenges are diagnosed rather than executed.
191
199
 
192
200
  Browser-profile normalization is intentionally limited to reducing obvious runtime inconsistencies that would otherwise change page behavior during extraction.
193
201
 
data/SKILL.md CHANGED
@@ -10,6 +10,7 @@ Use this skill for agent web retrieval.
10
10
 
11
11
  Start by loading this skill, then use `fetch_util` as the default first tool for ordinary web-page retrieval and search.
12
12
  Use it first because it usually returns cleaner, more agent-friendly markdown than generic web fetch, and it is cheap enough for repeated multi-pass retrieval.
13
+ `fetch_util` is primarily an agent retrieval tool: prefer bounded access to public information and inspect warnings when content is incomplete.
13
14
 
14
15
  ## Default Rule
15
16
 
@@ -27,6 +28,13 @@ Use it first because it usually returns cleaner, more agent-friendly markdown th
27
28
  - searching the web and returning compact agent-friendly results
28
29
  - getting cleaner markdown from noisy pages, docs, search pages, or light interstitials
29
30
 
31
+ ## Browser Challenges
32
+
33
+ - Browser fetch may let the site's delivered JavaScript or WebAssembly proof-of-work, such as Anubis, complete within the configured browser `timeout` and the normal cookie session.
34
+ - This is bounded native browser execution, not a custom solver. `timeout` supplies the challenge-observation budget; `wait` only affects applicable post-load stabilization paths.
35
+ - If the challenge remains unresolved, treat the result as an explicit interstitial and inspect `warnings`, `suspect`, and `content_type`.
36
+ - CAPTCHA solving, account or paywall circumvention, identity deception, and high-throughput scraping optimization are out of scope.
37
+
30
38
  ## Core Commands
31
39
 
32
40
  Fetch known URLs (returns pure markdown by default):
@@ -62,9 +70,11 @@ bundle exec exe/fetch_util search ruby language --limit 8
62
70
  - if the user gives you URLs, use `fetch_util fetch` first
63
71
  - if the user needs discovery, use `fetch_util search` first; for a context-efficient first pass, consider an explicit budget such as `--limit 8`
64
72
  - add `--verbose-search` and inspect ordered source `diagnostics` when results are empty or suspicious, or when source health matters
65
- - search defaults to direct HTTP Brave and Bing; explicit sources are `brave`, `bing`, `duckduckgo`, `google`, and `ecosia`
73
+ - search defaults to direct HTTP Brave, Bing, and Yahoo; explicit sources are `brave`, `bing`, `duckduckgo`, `google`, `ecosia`, and `yahoo`
66
74
  - search always emits one JSON object and normally returns exactly `{query, results}`; `--verbose-search` adds source diagnostics plus per-result source provenance and ranks
67
- - search has one finite shared source deadline, does not bypass challenges, decodes known engine wrappers, and preserves healthy peer results when a source fails; `empty` and `query_mismatch` are finite source outcomes
75
+ - search has one finite shared source deadline, diagnoses direct HTTP challenges rather than executing or bypassing them, decodes known engine wrappers, and preserves healthy peer results when a source fails; after the initial request, Yahoo retries generic `failed`, HTTP 429, and HTTP 5xx outcomes up to two times within the same deadline
76
+ - `query_mismatch` retains candidate evidence at the transport boundary instead of forcing an empty query; explicit source unions expose it, while the separate default structured-query policy may select Yahoo's URL set
77
+ - for default quoted, delimiter-bearing, or scoped queries, a healthy Yahoo result set defines returned URLs while matching peers retain provenance; explicit `--source` selections preserve the requested union
68
78
  - after search, select only 1-3 direct result URLs, then run `fetch_util fetch` on those destinations; use `--format json` or `--format jsonl` and inspect `warnings`, `suspect`, and `content_type` as needed
69
79
  - if the task is a normal web roundup (for example, checking several news homepages), still use `fetch_util` first; do not skip straight to built-in web fetch just because the URLs are already known
70
80
  - if you are in a subagent without the `skill` tool, treat `fetch_util` as a normal installed CLI and call it directly
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FetchUtil
4
+ class Browser
5
+ module Stabilization
6
+ module Anubis
7
+ ANUBIS_POLL = 0.25
8
+
9
+ private
10
+
11
+ def wait_for_anubis_challenge(page)
12
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
13
+ state = anubis_page_state(page)
14
+ if !valid_anubis_state?(state) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
15
+ state = anubis_page_state(page)
16
+ end
17
+ return false unless valid_anubis_state?(state) && state["challenge"] == true
18
+
19
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ return false unless remaining.positive?
21
+
22
+ retry_until_timeout(remaining, interval: ANUBIS_POLL) do
23
+ state = anubis_page_state(page)
24
+ next false unless valid_anubis_state?(state)
25
+ next false unless state["challenge"] == false
26
+
27
+ state["document_ready"] == true && state["body_present"] == true && state["body_text_present"] == true
28
+ end
29
+ end
30
+
31
+ def valid_anubis_state?(state)
32
+ state.is_a?(Hash) &&
33
+ [true, false].include?(state["challenge"]) &&
34
+ [true, false].include?(state["document_ready"]) &&
35
+ [true, false].include?(state["body_present"]) &&
36
+ [true, false].include?(state["body_text_present"]) &&
37
+ state["url"].is_a?(String) && !state["url"].empty?
38
+ end
39
+
40
+ def anubis_page_state(page)
41
+ safe_evaluate(page, <<~JS, default: {})
42
+ (() => {
43
+ const body = document.body;
44
+ const bodyText = body ? (body.innerText || '').replace(/\s+/g, ' ').trim() : '';
45
+ const anubisRoot = document.querySelector('#anubis_challenge');
46
+ const anubisScript = document.querySelector('script[src*="/.within.website/x/cmd/anubis/"]');
47
+ const visibleRoot = anubisRoot && (() => {
48
+ const style = window.getComputedStyle(anubisRoot);
49
+ const rect = anubisRoot.getBoundingClientRect();
50
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
51
+ })();
52
+ const makingSure = /making sure you're not a bot/i.test((document.title || '') + ' ' + bodyText);
53
+ const protectedChallenge = /protected by anubis/i.test(bodyText) &&
54
+ /(enable javascript to get past this challenge|please wait a moment while we ensure the security of your connection|loading|calculating|challenge:\s*anubis)/i.test(bodyText);
55
+ const challenge = Boolean((visibleRoot || anubisScript) && (makingSure || protectedChallenge));
56
+ return {
57
+ challenge,
58
+ document_ready: document.readyState === 'complete',
59
+ body_present: Boolean(body),
60
+ body_text_present: bodyText.length > 0,
61
+ url: location.href
62
+ };
63
+ })()
64
+ JS
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -38,6 +38,8 @@ module FetchUtil
38
38
  private
39
39
 
40
40
  def stabilize_page(page, url)
41
+ wait_for_anubis_challenge(page)
42
+
41
43
  if (profile = matching_stabilization_profile(url, PAGE_FLOW_STABILIZATION_PROFILES))
42
44
  return send(profile.fetch(:strategy), page)
43
45
  end
@@ -5,9 +5,11 @@ module FetchUtil
5
5
  module Stabilization
6
6
  autoload :PageFlow, "fetch_util/browser/stabilization/page_flow"
7
7
  autoload :SpaHydration, "fetch_util/browser/stabilization/spa_hydration"
8
+ autoload :Anubis, "fetch_util/browser/stabilization/anubis"
8
9
 
9
10
  include PageFlow
10
11
  include SpaHydration
12
+ include Anubis
11
13
  end
12
14
  end
13
15
  end
@@ -71,7 +71,7 @@ module FetchUtil
71
71
  end
72
72
 
73
73
  desc "search QUERY", "Search across configured engines and aggregate results"
74
- option :source, type: :array, default: FetchUtil::Searcher::DEFAULT_SOURCES, desc: "Search sources"
74
+ option :source, type: :array, default: nil, desc: "Search sources"
75
75
  option :limit, type: :numeric, default: nil, desc: "Maximum results; omit to return every result in the fetched response"
76
76
  option :verbose_search, type: :boolean, default: false, desc: "Include per-result search provenance"
77
77
  def search(*terms)
@@ -34,19 +34,24 @@ module FetchUtil
34
34
  "bing" => { url: "https://www.bing.com/search?q=%{query}&setlang=en-US&cc=US", hosts: %w[www.bing.com cn.bing.com] },
35
35
  "duckduckgo" => { url: "https://html.duckduckgo.com/html/?q=%{query}", hosts: %w[html.duckduckgo.com] },
36
36
  "google" => { url: "https://www.google.com/search?q=%{query}", hosts: %w[www.google.com] },
37
- "ecosia" => { url: "https://www.ecosia.org/search?q=%{query}", hosts: %w[www.ecosia.org] }
37
+ "ecosia" => { url: "https://www.ecosia.org/search?q=%{query}", hosts: %w[www.ecosia.org] },
38
+ "yahoo" => { url: "https://search.yahoo.com/search?p=%{query}", hosts: %w[search.yahoo.com] }
38
39
  }.freeze
39
40
  DEFAULT_TIMEOUT = 10.0
41
+ YAHOO_RECOVERY_ATTEMPTS = 2
40
42
  FAILURE_REASONS = %w[challenge failed host http_status parse query_mismatch redirect size timeout].freeze
41
43
  RELEVANCE_HEALTH_CANDIDATE_COUNT = 3
42
44
  QUERY_FUNCTION_WORDS = %w[
43
45
  a an and are at be been being but by can could did do does for from had has have how i if in is it me my no not of on or our shall
44
46
  should that the their them then there these they this to was we were what when where which who why will with would you your
45
47
  ].freeze
48
+ SCOPED_QUERY_TERM = /(?:\A|\s)[+-]?[[:alpha:]][[:alnum:]_-]*:(?:"[^"]*"|'[^']*'|\S+)/
49
+ NEGATED_QUERY_TERM = /(?:\A|\s)-\S+/
46
50
  WRAPPER_HOSTS = {
47
51
  "bing" => %w[bing.com www.bing.com cn.bing.com],
48
52
  "duckduckgo" => %w[duckduckgo.com www.duckduckgo.com html.duckduckgo.com],
49
- "google" => %w[google.com www.google.com]
53
+ "google" => %w[google.com www.google.com],
54
+ "yahoo" => %w[r.search.yahoo.com]
50
55
  }.freeze
51
56
 
52
57
  def initialize(sources: SOURCES.keys, timeout: DEFAULT_TIMEOUT, clock: nil, http_client: nil, html_parser: nil)
@@ -62,11 +67,28 @@ module FetchUtil
62
67
  @html_parser = html_parser || ->(body) { Nokogiri::HTML(body) }
63
68
  end
64
69
 
65
- def search(query)
70
+ def self.candidates_match_query?(query, candidates)
71
+ candidates.first(RELEVANCE_HEALTH_CANDIDATE_COUNT).any? do |candidate|
72
+ candidate_matches_query?(query, candidate)
73
+ end
74
+ end
75
+
76
+ def self.candidate_matches_query?(query, candidate)
77
+ query_terms = meaningful_query_terms(normalized_query_text(query))
78
+ return true if query_terms.length < 3
79
+
80
+ candidate_terms = normalized_terms("#{candidate.title} #{candidate.snippet}")
81
+ (query_terms & candidate_terms).length >= 2
82
+ end
83
+
84
+ def search(query, timeout: @timeout)
66
85
  query = query.to_s.strip
67
86
  raise ArgumentError, "query must not be empty" if query.empty?
68
87
 
69
- deadline = clock.call + timeout
88
+ request_timeout = Float(timeout)
89
+ raise ArgumentError, "timeout must be positive" unless request_timeout.positive?
90
+
91
+ deadline = clock.call + request_timeout
70
92
  responses = Array.new(sources.length)
71
93
  threads = sources.each_with_index.map do |source, index|
72
94
  Thread.new { responses[index] = search_source(source, query, deadline) }
@@ -82,8 +104,10 @@ module FetchUtil
82
104
  def search_source(source, query, deadline)
83
105
  started_at = clock.call
84
106
  url = build_url(source, query)
85
- result = http_client.get(url, deadline: deadline, allowed_hosts: SOURCES.fetch(source).fetch(:hosts))
86
- return failure(source, result.reason, elapsed_since(started_at), result.final_url) if result.is_a?(HttpFailure)
107
+ result = yahoo_response(source, url, deadline)
108
+ if result.is_a?(HttpFailure)
109
+ return failure(source, result.reason, elapsed_since(started_at), result.final_url)
110
+ end
87
111
  outcome = within_deadline(deadline) do
88
112
  document = html_parser.call(result.body)
89
113
  next [:failed, "challenge"] if challenge?(source, result, document)
@@ -92,13 +116,23 @@ module FetchUtil
92
116
 
93
117
  candidates = parse_candidates(source, document)
94
118
  next [:ok, candidates] if candidates.empty?
95
- next [:failed, "query_mismatch"] unless query_matches_candidates?(query, candidates)
119
+ next [:suspect, candidates] unless self.class.candidates_match_query?(query, candidates)
96
120
 
97
121
  [:ok, candidates]
98
122
  end
99
123
  elapsed_ms = elapsed_since(started_at)
100
- return failure(source, outcome.last, elapsed_ms, result.final_url) if outcome.first == :failed
124
+ return failure(source, outcome[1], elapsed_ms, result.final_url, candidates: outcome[2] || []) if outcome.first == :failed
101
125
  return empty(source, elapsed_ms, result.final_url) if outcome.first == :empty
126
+ if outcome.first == :suspect
127
+ return SourceResponse.new(
128
+ source: source,
129
+ status: "ok",
130
+ candidates: outcome.last,
131
+ elapsed_ms: elapsed_ms,
132
+ final_url: result.final_url,
133
+ reason: "query_mismatch"
134
+ )
135
+ end
102
136
 
103
137
  candidates = outcome.last
104
138
  return failure(source, "parse", elapsed_ms, result.final_url) if candidates.empty?
@@ -113,13 +147,31 @@ module FetchUtil
113
147
  failure(source, "failed", elapsed_since(started_at), url)
114
148
  end
115
149
 
150
+ def yahoo_response(source, url, deadline)
151
+ return http_client.get(url, deadline: deadline, allowed_hosts: SOURCES.fetch(source).fetch(:hosts)) unless source == "yahoo"
152
+
153
+ result = http_client.get(url, deadline: deadline, allowed_hosts: SOURCES.fetch(source).fetch(:hosts))
154
+ YAHOO_RECOVERY_ATTEMPTS.times do
155
+ break unless yahoo_retryable?(result) && deadline > clock.call
156
+
157
+ result = http_client.get(url, deadline: deadline, allowed_hosts: SOURCES.fetch(source).fetch(:hosts))
158
+ end
159
+ result
160
+ end
161
+
162
+ def yahoo_retryable?(result)
163
+ return result.reason == "failed" if result.is_a?(HttpFailure)
164
+
165
+ result.is_a?(HttpResponse) && (result.status == 429 || result.status.between?(500, 599))
166
+ end
167
+
116
168
  def build_url(source, query)
117
169
  SOURCES.fetch(source).fetch(:url) % { query: URI.encode_www_form_component(query) }
118
170
  end
119
171
 
120
172
  def parse_candidates(source, document)
121
173
  result_nodes(source, document).filter_map do |node|
122
- next if excluded_node?(node)
174
+ next if excluded_node?(source, node)
123
175
 
124
176
  anchor = result_anchor(source, node)
125
177
  next unless anchor
@@ -135,37 +187,27 @@ module FetchUtil
135
187
  end
136
188
  end
137
189
 
138
- def query_matches_candidates?(query, candidates)
139
- normalized_query = normalized_query_text(query)
140
- query_terms = meaningful_query_terms(normalized_query)
141
- return true unless relevance_gate_applies?(normalized_query, query_terms)
142
-
143
- candidates.first(RELEVANCE_HEALTH_CANDIDATE_COUNT).any? do |candidate|
144
- candidate_terms = normalized_terms("#{candidate.title} #{candidate.snippet}")
145
- (query_terms & candidate_terms).length >= 2
146
- end
190
+ def self.meaningful_query_terms(query)
191
+ semantic_query = query.gsub(SCOPED_QUERY_TERM, " ").gsub(NEGATED_QUERY_TERM, " ")
192
+ normalized_terms(semantic_query).reject { |term| QUERY_FUNCTION_WORDS.include?(term) }
147
193
  end
148
194
 
149
- def relevance_gate_applies?(query, query_terms)
150
- return false if query_terms.length < 3
151
-
152
- !query.match?(%r{["']|(?:\A|\s)[+-]?[[:alpha:]][[:alnum:]_-]*:|[+#/]|(?:\b[[:alpha:]]\.){2,}|\d|\b[[:upper:]]{2,}\b})
153
- end
154
-
155
- def meaningful_query_terms(query)
156
- normalized_terms(query).reject { |term| QUERY_FUNCTION_WORDS.include?(term) }
195
+ def self.normalized_terms(text)
196
+ normalized = normalized_query_text(text)
197
+ normalized = normalized.gsub(/([[:upper:]]+)([[:upper:]][[:lower:]])/, '\1 \2')
198
+ normalized = normalized.gsub(/([[:lower:]\d])([[:upper:]])/, '\1 \2')
199
+ normalized.downcase.scan(/[[:alnum:]]+/).uniq
157
200
  end
158
201
 
159
- def normalized_terms(text)
160
- normalized_query_text(text).downcase.scan(/[[:alnum:]]+/).uniq
161
- end
162
-
163
- def normalized_query_text(text)
202
+ def self.normalized_query_text(text)
164
203
  text.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: " ").unicode_normalize(:nfc)
165
204
  end
166
205
 
206
+ private_class_method :meaningful_query_terms, :normalized_terms, :normalized_query_text
207
+
167
208
  def result_nodes(source, document)
168
209
  return brave_result_nodes(document) if source == "brave"
210
+ return yahoo_result_nodes(document) if source == "yahoo"
169
211
 
170
212
  selector = {
171
213
  "bing" => "#b_results li.b_algo",
@@ -183,6 +225,11 @@ module FetchUtil
183
225
  (current.to_a + legacy).uniq
184
226
  end
185
227
 
228
+ def yahoo_result_nodes(document)
229
+ nodes = document.css("#web .algo, #web .algo-sr, #web .dd.algo").uniq
230
+ nodes.reject { |node| nodes.any? { |other| other != node && other.ancestors.include?(node) } }
231
+ end
232
+
186
233
  def legacy_brave_result?(node)
187
234
  node.at_css("h2, h3, .title.search-snippet-title") && result_anchor("brave", node)
188
235
  end
@@ -197,6 +244,7 @@ module FetchUtil
197
244
  when "bing" then node.at_css("h2 a[href]")
198
245
  when "duckduckgo" then node.at_css("a.result__a[href], h2 a[href]")
199
246
  when "google" then node.at_css("a[href]:has(h3)") || node.at_css("h3")&.ancestors("a[href]")&.first
247
+ when "yahoo" then node.at_css("h3 a[href], .compTitle a[href]")
200
248
  else node.at_css("h2 a[href], h3 a[href], a[href]:has(h3)")
201
249
  end
202
250
  end
@@ -213,21 +261,31 @@ module FetchUtil
213
261
  "bing" => ".b_caption p, .b_paractl",
214
262
  "duckduckgo" => ".result__snippet",
215
263
  "google" => ".VwiC3b, .aCOpRe, [data-sncf]",
216
- "ecosia" => ".result-snippet, .result__description"
264
+ "ecosia" => ".result-snippet, .result__description",
265
+ "yahoo" => ".compText, .compText p"
217
266
  }.fetch(source)
218
267
  end
219
268
 
220
- def excluded_node?(node)
269
+ def excluded_node?(source, node)
270
+ return yahoo_excluded_node?(node) if source == "yahoo"
271
+
221
272
  node.xpath("ancestor-or-self::*").any? do |ancestor|
222
273
  value = [ancestor["class"], ancestor["id"], ancestor["data-testid"]].compact.join(" ").downcase
223
274
  value.match?(/\b(ad|ads|advert|enrichment|knowledge|llm|nav|pagination|related|answer)\b/)
224
275
  end
225
276
  end
226
277
 
278
+ def yahoo_excluded_node?(node)
279
+ node.xpath("ancestor-or-self::*").any? do |ancestor|
280
+ value = [ancestor["class"], ancestor["id"], ancestor["data-testid"]].compact.join(" ").downcase
281
+ value.match?(/\b(ad|ads|advert|control|controls|enrichment|knowledge|llm|lookalike|nav|navigation|pagination|related|right[-_ ]?rail|assist)\b/)
282
+ end
283
+ end
284
+
227
285
  def destination(source, href)
228
286
  value = href.to_s.strip
229
287
  value = decode_bing(value) if source == "bing"
230
- value = decode_wrapper(source, value) if %w[google duckduckgo].include?(source)
288
+ value = decode_wrapper(source, value) if %w[google duckduckgo yahoo].include?(source)
231
289
  uri = URI.parse(value)
232
290
  return unless uri.is_a?(URI::HTTP) && uri.host
233
291
 
@@ -252,6 +310,8 @@ module FetchUtil
252
310
 
253
311
  def decode_wrapper(source, value)
254
312
  uri = URI.parse(value)
313
+ return decode_yahoo_wrapper(uri, value) if source == "yahoo"
314
+
255
315
  key = source == "google" ? %w[q url] : %w[uddg]
256
316
  path = wrapper_path(source)
257
317
  return value unless uri.path == path && (uri.host.nil? || wrapper_host?(source, uri.host))
@@ -261,6 +321,21 @@ module FetchUtil
261
321
  value
262
322
  end
263
323
 
324
+ def decode_yahoo_wrapper(uri, value)
325
+ return value unless wrapper_host?("yahoo", uri.host)
326
+
327
+ segment = uri.path.split("/").find { |part| part.start_with?("RU=") }
328
+ return value unless segment
329
+
330
+ decoded = URI::DEFAULT_PARSER.unescape(segment.delete_prefix("RU="))
331
+ destination = URI.parse(decoded)
332
+ return nil unless destination.is_a?(URI::HTTP) && destination.host
333
+
334
+ destination.to_s
335
+ rescue URI::InvalidURIError
336
+ nil
337
+ end
338
+
264
339
  def engine_url?(source, value)
265
340
  host = URI.parse(value).host
266
341
  same_engine_host?(source, host) || wrapper_host?(source, host)
@@ -302,7 +377,8 @@ module FetchUtil
302
377
  "bing" => /there are no results|no results found/,
303
378
  "duckduckgo" => /no results|no more results/,
304
379
  "google" => /did not match any documents|no results found/,
305
- "ecosia" => /no results found|we couldn't find/
380
+ "ecosia" => /no results found|we couldn't find/,
381
+ "yahoo" => /no results found|we couldn't find|did not match any results/
306
382
  }
307
383
  text.match?(patterns.fetch(source))
308
384
  end
@@ -313,10 +389,17 @@ module FetchUtil
313
389
  normalized_text(visible).downcase
314
390
  end
315
391
 
316
- def failure(source, reason, elapsed_ms, final_url)
392
+ def failure(source, reason, elapsed_ms, final_url, candidates: [])
317
393
  reason = reason.to_s
318
394
  reason = "failed" unless FAILURE_REASONS.include?(reason)
319
- SourceResponse.new(source: source, status: "failed", elapsed_ms: elapsed_ms, final_url: final_url, reason: reason)
395
+ SourceResponse.new(
396
+ source: source,
397
+ status: "failed",
398
+ candidates: candidates,
399
+ elapsed_ms: elapsed_ms,
400
+ final_url: final_url,
401
+ reason: reason
402
+ )
320
403
  end
321
404
 
322
405
  def empty(source, elapsed_ms, final_url)
@@ -5,8 +5,9 @@ require "uri"
5
5
 
6
6
  module FetchUtil
7
7
  class Searcher
8
- DEFAULT_SOURCES = %w[brave bing].freeze
9
-
8
+ DEFAULT_SOURCES = %w[brave bing yahoo].freeze
9
+ STRUCTURED_QUERY_AUTHORITY = "yahoo"
10
+ STRUCTURED_QUERY = /"[^"]+"|'[^']+'|\(|\)|\[|\]|\{|\}|(?:\A|\s)[+-]?[[:alpha:]][[:alnum:]_-]*:/
10
11
  autoload :ResultFiltering, "fetch_util/searcher/result_filtering"
11
12
  include ResultFiltering
12
13
  private_constant :ResultFiltering
@@ -14,6 +15,7 @@ module FetchUtil
14
15
  def initialize(transport: nil, request_log: RequestLog.new, sources: nil, limit: nil, verbose: false,
15
16
  timeout: SearchTransport::DEFAULT_TIMEOUT)
16
17
  @request_log = request_log
18
+ @sources_explicit = !sources.nil?
17
19
  @sources = Array(sources || DEFAULT_SOURCES).map(&:to_s).uniq
18
20
  unknown = @sources - SearchTransport::SOURCES.keys
19
21
  raise ArgumentError, "unsupported search source: #{unknown.first}" if unknown.any?
@@ -33,7 +35,7 @@ module FetchUtil
33
35
 
34
36
  payload = {
35
37
  query: encoded_query,
36
- results: formatted_results(apply_limit(aggregate(responses)))
38
+ results: formatted_results(apply_limit(aggregate(responses, encoded_query)))
37
39
  }
38
40
  payload[:diagnostics] = diagnostics(responses) if @verbose
39
41
  payload
@@ -58,17 +60,18 @@ module FetchUtil
58
60
  "search://#{@sources.join(",")}?q=#{CGI.escape(query)}"
59
61
  end
60
62
 
61
- def aggregate(responses)
63
+ def aggregate(responses, query)
62
64
  parsed = {}
63
- max_size = 0
65
+ structured_query = query.match?(STRUCTURED_QUERY)
64
66
 
65
67
  @sources.each do |source|
66
68
  response = responses.find { |item| item.source == source }
67
- items = response ? response.candidates.filter_map { |candidate| normalized_candidate(candidate) } : []
68
- parsed[source] = items
69
- max_size = [max_size, items.length].max
69
+ parsed[source] = response ? response.candidates.filter_map { |candidate| normalized_candidate(candidate) } : []
70
70
  end
71
71
 
72
+ apply_structured_query_authority!(parsed, responses) if structured_query
73
+ max_size = parsed.values.map(&:length).max || 0
74
+
72
75
  items = []
73
76
  seen = {}
74
77
 
@@ -92,6 +95,23 @@ module FetchUtil
92
95
  items
93
96
  end
94
97
 
98
+ def apply_structured_query_authority!(parsed, responses)
99
+ return if @sources_explicit
100
+ return unless @sources.include?(STRUCTURED_QUERY_AUTHORITY)
101
+
102
+ authority = responses.find { |response| response.source == STRUCTURED_QUERY_AUTHORITY }
103
+ return unless authority&.status == "ok" && authority.reason.nil?
104
+
105
+ authority_urls = parsed.fetch(STRUCTURED_QUERY_AUTHORITY).map { |item| item[:url] }
106
+ return if authority_urls.empty?
107
+
108
+ @sources.each do |source|
109
+ next if source == STRUCTURED_QUERY_AUTHORITY
110
+
111
+ parsed[source].select! { |item| authority_urls.include?(item[:url]) }
112
+ end
113
+ end
114
+
95
115
  def build_result(source, item)
96
116
  result = {
97
117
  title: item[:title],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetchUtil
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fetch_util
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - hmdne
@@ -86,6 +86,7 @@ files:
86
86
  - lib/fetch_util/browser/site_stabilization/social_platforms.rb
87
87
  - lib/fetch_util/browser/site_stabilization/travel_and_lodging.rb
88
88
  - lib/fetch_util/browser/stabilization.rb
89
+ - lib/fetch_util/browser/stabilization/anubis.rb
89
90
  - lib/fetch_util/browser/stabilization/page_flow.rb
90
91
  - lib/fetch_util/browser/stabilization/spa_hydration.rb
91
92
  - lib/fetch_util/cli.rb