module AccomSearch
Find accommodation listings via DuckDuckGo HTML search; sort by price ascending. HTTP is injected via fetch lambdas โ no live network in tests.
Constants
- ACCOM_PATTERN
- AVAILABILITY_AVAILABLE
- AVAILABILITY_ONLY_LEFT
- AVAILABILITY_SOLD_OUT
- DDG_HTML_URL
- DEFAULT_LIMIT
- MAX_LIMIT
- MIN_LIMIT
- PRICE_PATTERN
- REJECT_PATTERN
Public Instance Methods
Source
# File lib/accom_search.rb, line 138 def accommodation_like?(title, snippet, url) text = "#{title} #{snippet} #{url}" text.match?(ACCOM_PATTERN) || url.to_s.match?(/booking\.com|wotif\.com|stayz\.com/i) end
Source
# File lib/accom_search.rb, line 54 def build_ddg_query(location:, check_in:, nights:) date_label = check_in.strftime("%-d %b %Y") quoted = location.to_s.strip %("#{quoted}" accommodation OR hotel OR motel #{date_label} #{nights} night Australia site:booking.com OR site:wotif.com OR site:stayz.com.au) end
Source
# File lib/accom_search.rb, line 60 def build_search_url(location:, check_in:, nights:) query = build_ddg_query(location: location, check_in: check_in, nights: nights) "#{DDG_HTML_URL}?q=#{CGI.escape(query)}" end
Source
# File lib/accom_search.rb, line 65 def clean_url(href) return "" if href.nil? || href.to_s.strip.empty? href = href.to_s.strip href = "https:#{href}" if href.start_with?("//") if href.include?("uddg=") decoded = URI.decode_www_form(URI.parse(href).query.to_s).to_h["uddg"] return decoded if decoded && !decoded.empty? end href rescue StandardError href.to_s end
Source
# File lib/accom_search.rb, line 104 def extract_availability(text) body = text.to_s return "sold out" if body.match?(AVAILABILITY_SOLD_OUT) if (match = body.match(AVAILABILITY_ONLY_LEFT)) count = match[1] return "#{count} #{count.to_i == 1 ? 'room' : 'rooms'} left" end return "available" if body.match?(AVAILABILITY_AVAILABLE) "check site" end
Source
# File lib/accom_search.rb, line 123 def extract_location(snippet, fallback) match = snippet.to_s.match(/\bin\s+([A-Za-z][A-Za-z\s'-]+?)(?:\s+QLD|\s+NSW|\s+VIC|\s+SA|\s+WA|\s+TAS|\s+NT|\s+ACT|\b)/i) location = match ? match[1].strip : nil return TextUtil.title_words(location) unless location.to_s.strip.empty? TextUtil.title_words(fallback) end
Source
# File lib/accom_search.rb, line 116 def extract_name(title) name = title.to_s.strip name = name.sub(/\s+[-ยท|]\s+(?:Booking\.com|Wotif|Stayz|Google Travel|TripAdvisor)\z/i, "") name = name.sub(/\s+[-ยท|]\s+[^-|ยท]+(?:\.com|\.com\.au)\z/i, "") TextUtil.squish(name) end
Source
# File lib/accom_search.rb, line 89 def extract_price(text) match = text.to_s.match(PRICE_PATTERN) return nil unless match cleaned = match[1].to_s.delete(",") value = Float(cleaned) value.positive? ? value : nil rescue ArgumentError nil end
Source
# File lib/accom_search.rb, line 100 def format_price_label(price) format("$%.0f/night", price.to_f) end
Source
# File lib/accom_search.rb, line 143 def mentions_location?(text, location) words = location.to_s.downcase.split(/\s+/) body = text.to_s.downcase words.all? { |word| body.include?(word) } end
Source
# File lib/accom_search.rb, line 149 def parse_ddg_results(html, location:, limit: DEFAULT_LIMIT) doc = Nokogiri::HTML(html.to_s) doc.css(".result").filter_map do |result| link_el = result.at_css(".result__a") next unless link_el title = link_el.text.to_s.strip snippet = result.at_css(".result__snippet")&.text&.strip.to_s url = clean_url(link_el["href"]) next if title.empty? || url.empty? next if reject_result?(title, snippet, url) next unless accommodation_like?(title, snippet, url) next unless mentions_location?("#{title} #{snippet}", location) combined = "#{title} #{snippet}" price = extract_price(combined) { name: extract_name(title), location: extract_location(snippet, location), price: price, price_label: price ? format_price_label(price) : "price on site", description: trim_snippet(snippet), availability: extract_availability(combined), url: url } end rescue StandardError [] end
Source
# File lib/accom_search.rb, line 131 def reject_result?(title, snippet, url) text = "#{title} #{snippet} #{url}" return true if text.match?(REJECT_PATTERN) false end
Source
# File lib/accom_search.rb, line 186 def search(parsed, fetch:, limit: DEFAULT_LIMIT) url = build_search_url( location: parsed[:location], check_in: parsed[:check_in], nights: parsed[:nights] ) html = fetch.call(url) items = parse_ddg_results(html, location: parsed[:location], limit: limit) sort_items(items).first(limit) rescue StandardError [] end
Source
# File lib/accom_search.rb, line 179 def sort_items(items) items.sort_by do |item| price = item[:price] [price.nil? ? 1 : 0, price || Float::INFINITY, item[:name].to_s.downcase] end end
Source
# File lib/accom_search.rb, line 199 def travel_search_url(parsed) AccomTravelUrl.build_search_url( location: parsed[:location], check_in: parsed[:check_in], check_out: parsed[:check_out], nights: parsed[:nights] ) end
Source
# File lib/accom_search.rb, line 81 def trim_snippet(text, max_sentences: 2) text = text.to_s.strip.gsub(/\s+/, " ") return "" if text.empty? sentences = text.split(/(?<=[.!?])\s+/) sentences.first(max_sentences).join(" ").strip end