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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +18 -10
- data/SKILL.md +12 -2
- data/lib/fetch_util/browser/stabilization/anubis.rb +69 -0
- data/lib/fetch_util/browser/stabilization/page_flow.rb +2 -0
- data/lib/fetch_util/browser/stabilization.rb +2 -0
- data/lib/fetch_util/cli.rb +1 -1
- data/lib/fetch_util/search_transport.rb +120 -37
- data/lib/fetch_util/searcher.rb +28 -8
- data/lib/fetch_util/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d9dd7f8e6ba30f2d7ad5c9818fc214add21a18baa69f0e1081fe124d7781c35
|
|
4
|
+
data.tar.gz: 22e9bbc8ad10f85745e2791f196ce2633f4b0a795c95ac94f867ef3696d2053f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:`
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
@@ -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
|
data/lib/fetch_util/cli.rb
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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 =
|
|
86
|
-
|
|
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 [:
|
|
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
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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(
|
|
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)
|
data/lib/fetch_util/searcher.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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],
|
data/lib/fetch_util/version.rb
CHANGED
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.
|
|
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
|