Class: Pikuri::Tool::Search::DuckDuckGo
- Inherits:
-
Object
- Object
- Pikuri::Tool::Search::DuckDuckGo
- Defined in:
- lib/pikuri/tool/search/duckduckgo.rb
Overview
Performs a DuckDuckGo search by scraping html.duckduckgo.com, returning
hits as Result rows. Split into a thin HTTP fetch (#search) and a pure
parser (.parse) so tests exercise the parser against fixture HTML.
Engines#search owns the final Markdown rendering. Constructed with no
arguments, sharing the uniform +#search+/+#label+ provider shape with the
keyed Brave / Exa.
Privacy posture
DDG doesn't save your IP or tie a per-user profile to queries, and proxies requests so downstream providers can't build a search history — real, but DDG relays web results over Bing, so query content still reaches Microsoft (who has no no-training pledge). Better than Exa for sensitive queries, worse than Brave; for anything genuinely embarrassing, don't search the web at all.
Constant Summary collapse
- ENDPOINT =
Returns HTML search endpoint.
'https://html.duckduckgo.com/html/'- USER_AGENT =
Returns User-Agent sent with each request; DDG often rejects a missing or obviously-bot UA.
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' \ '(KHTML, like Gecko) Chrome/120.0 Safari/537.36'
- DEFAULT_MAX_RESULTS =
Returns default number of results returned.
10- LIMITER =
Returns paces calls (DDG bans IPs that hammer the HTML endpoint) and circuit-breaks 5 min on Engines::Unavailable.
RateLimiter.new(min_interval: 2.0, cooldown: 300.0)
Class Method Summary collapse
-
.extract_url(href) ⇒ String
Decode DDG's
//duckduckgo.com/l/?uddg=<encoded>redirect wrapper to the real target URL. -
.parse(html, max_results: DEFAULT_MAX_RESULTS) ⇒ Array<Result>
Parse a
html.duckduckgo.comresult page into Result rows (++ highlights stripped).
Instance Method Summary collapse
-
#label ⇒ String
Short provider label for Engines.
-
#search(query, max_results: DEFAULT_MAX_RESULTS, cancellable: Pikuri::Agent::Control::Cancellable::NEVER) ⇒ Array<Result>
Fetch results for
queryas anArray<Result>.
Class Method Details
.extract_url(href) ⇒ String
Decode DDG's //duckduckgo.com/l/?uddg=<encoded> redirect wrapper to
the real target URL.
161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/pikuri/tool/search/duckduckgo.rb', line 161 def self.extract_url(href) return href if href.nil? || href.empty? uri = URI.parse(href.start_with?('//') ? "https:#{href}" : href) return href unless uri.host&.end_with?('duckduckgo.com') && uri.path == '/l/' params = URI.decode_www_form(uri.query.to_s).to_h params['uddg'] || href rescue URI::InvalidURIError href end |
.parse(html, max_results: DEFAULT_MAX_RESULTS) ⇒ Array<Result>
Parse a html.duckduckgo.com result page into Result rows (++
highlights stripped). On zero result nodes: a genuine "no results" page
returns [] (so Engines#search renders its stub); the anomaly/CAPTCHA
modal raises Engines::Unavailable; anything else raises RuntimeError
(likely a layout change) — so an IP soft-block surfaces rather than
masquerading as an empty search.
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/pikuri/tool/search/duckduckgo.rb', line 85 def self.parse(html, max_results: DEFAULT_MAX_RESULTS) doc = Nokogiri::HTML(html) results = doc.css('div.result.web-result').take(max_results).filter_map do |node| title_link = node.at_css('a.result__a') next nil if title_link.nil? snippet = node.at_css('a.result__snippet') Result.new( url: extract_url(title_link['href']), title: title_link.text.strip, body: snippet&.text&.strip.to_s ) end if results.empty? return [] if genuine_no_results?(doc) = diagnose_empty(doc) raise(anomaly_modal?(doc) ? Engines::Unavailable : RuntimeError, ) end results end |
Instance Method Details
#label ⇒ String
Returns short provider label for Engines.
39 40 41 |
# File 'lib/pikuri/tool/search/duckduckgo.rb', line 39 def label 'DuckDuckGo' end |
#search(query, max_results: DEFAULT_MAX_RESULTS, cancellable: Pikuri::Agent::Control::Cancellable::NEVER) ⇒ Array<Result>
Fetch results for query as an Array<Result>. Throttled to one every
2s and circuit-broken 5 min after a soft-block (see LIMITER). The
caller (Engines#search) normalizes the query and wraps this in a cache.
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/pikuri/tool/search/duckduckgo.rb', line 57 def search(query, max_results: DEFAULT_MAX_RESULTS, cancellable: Pikuri::Agent::Control::Cancellable::NEVER) LIMITER.call(cancellable: cancellable) do response = Faraday.get(ENDPOINT, { q: query }, { 'User-Agent' => USER_AGENT }) unless response.success? if response.status == 429 || response.status >= 500 raise Engines::Unavailable, "HTTP #{response.status}" end raise "DuckDuckGo request failed: #{response.status} #{response.body}" end self.class.parse(response.body, max_results: max_results) end end |