class Beer
Constants
- AGGREGATE_REVIEW_PATTERN
- BEER_VENUE_PATTERN
- FACEBOOK_SNIPPET_NOISE_PATTERN
- GENERIC_VENUE_WORDS
- LISTING_URL_PATTERN
- REJECT_PLACE_PATTERN
Public Class Methods
Source
# File plugins/beer.rb, line 231 def self.address_search_query(place, location) "#{place} #{location} address" end
Source
# File plugins/beer.rb, line 302 def self.clean_facebook_snippet(snippet) text = snippet.to_s.strip return "" if text.empty? return "" if text.match?(FACEBOOK_SNIPPET_NOISE_PATTERN) && !text.match?(/\b(?:Street|St|Road|Rd|Avenue|Ave|Drive|Dr)\b/i) text = text.sub(/\d[\d,]*\s+likes?\s*[·•].*$/i, "").strip text = text.sub(/Location:\s*\d{4}\s*Ph:.*/i, "").strip text = text.sub(/\b(?:HOME|CONTACT US|STAY|MENU|ABOUT)\b.*$/i, "").strip cleaned = Duck.trim_snippet(text) cleaned.match?(FACEBOOK_SNIPPET_NOISE_PATTERN) && !cleaned.match?(/\d+\s+[A-Za-z]/) ? "" : cleaned end
Source
# File plugins/beer.rb, line 121 def self.clean_place_title(title) text = title.to_s.strip return "" if text.empty? if text.match?(/\A(?:home|menu|about us)\s*\|\s*/i) text = text.split(/\s*\|\s*/, 2).last.to_s else text = text.split(/\s*\|\s*/).first.to_s end text = text.sub(/\s+in\s+.+$/i, "").strip text.sub(/\s+-\s+.+$/, "").strip end
Source
# File plugins/beer.rb, line 280 def self.conflicting_venue_name?(place, text) norm_place = normalize_place_text(place) norm_text = normalize_place_text(text) norm_place.split.each do |location_word| next if location_word.length < 4 next unless norm_text.include?(location_word) GENERIC_VENUE_WORDS.each do |venue_type| next unless norm_place.include?(venue_type) && norm_text.include?(venue_type) place_middle = norm_place.match(/#{Regexp.escape(location_word)}\s+(\w+)\s+#{Regexp.escape(venue_type)}/) text_middle = norm_text.match(/#{Regexp.escape(location_word)}\s+(\w+)\s+#{Regexp.escape(venue_type)}/) next unless place_middle && text_middle return true if place_middle[1] != text_middle[1] end end false end
Source
# File plugins/beer.rb, line 342 def self.extract_address_from_ddg_html(html, place: nil) doc = Nokogiri::HTML(html.to_s) doc.css(".result").each do |result| title = result.at_css(".result__a")&.text&.strip.to_s snippet = result.at_css(".result__snippet")&.text&.strip next if snippet.nil? || snippet.empty? next if place && !place_matches_text?(place, title) && !place_matches_text?(place, snippet) address = extract_address_from_text(snippet) return address if address end nil end
Source
# File plugins/beer.rb, line 325 def self.extract_address_from_text(text) text = text.to_s.strip return nil if text.empty? if (match = text.match(/located at\s+([^.]+)/i)) address = match[1].strip return address if valid_address?(address) end if (match = text.match(/\b(\d+[^.]*?(?:Street|St|Road|Rd|Avenue|Ave|Drive|Dr|Parade|Pde|Way|Lane|Ln|Court|Ct)\b[^.]*)/i)) address = match[1].strip.sub(/\s+for\s+.+$/i, "").strip return address if valid_address?(address) end nil end
Source
# File plugins/beer.rb, line 185 def self.extract_best_candidate_from_ddg_html(html, location:) doc = Nokogiri::HTML(html.to_s) candidates = doc.css(".result").filter_map do |result| title = result.at_css(".result__a")&.text&.strip href = Duck.clean_url(result.at_css(".result__a")&.[]("href")) place_candidate(title, href, location: location) end candidates.max_by { |candidate| candidate[:score] } end
Source
# File plugins/beer.rb, line 196 def self.extract_best_place_from_ddg_html(html, location:) extract_best_candidate_from_ddg_html(html, location: location)&.dig(:name) end
Source
# File plugins/beer.rb, line 375 def self.extract_google_maps_review_from_text(text) text = text.to_s.strip return nil if text.empty? return nil if text.match?(AGGREGATE_REVIEW_PATTERN) if text.match?(/[★⭐]|\d(?:\.\d+)?\s*(?:stars?|★)/i) && (match = text.match(/"([^"]{15,})"/)) quote = match[1].strip return quote unless quote.match?(AGGREGATE_REVIEW_PATTERN) end if (match = text.match(/(?:^|[·•|])\s*"([^"]{15,})"/)) quote = match[1].strip return quote unless quote.match?(AGGREGATE_REVIEW_PATTERN) end if (match = text.match(/\d(?:\.\d+)?\s*[★⭐][^A-Za-z0-9"]*(.+)\z/)) review = Duck.trim_snippet(match[1].strip) return review unless review.empty? || review.match?(AGGREGATE_REVIEW_PATTERN) end extract_review_from_text(text) end
Source
# File plugins/beer.rb, line 135 def self.extract_name_from_facebook_title(title) if (match = title.match(/-\s*(.+?)\s*\|\s*Facebook\z/i)) return clean_place_title(match[1]) end if (match = title.match(/\A(.+?)\s*\|\s*.+\s+-\s*Facebook\z/i)) return clean_place_title(match[1]) end if (match = title.match(/\A(?:home\s*\|\s*)?(.+?)\s*\|\s*Facebook\z/i)) return clean_place_title(match[1]) end nil end
Source
# File plugins/beer.rb, line 151 def self.extract_name_from_google_maps_title(title) if (match = title.match(/\A(.+?)\s*[-·|]\s*Google\s+Maps\z/i)) return clean_place_title(match[1]) end nil end
Source
# File plugins/beer.rb, line 200 def self.extract_place_from_ddg_html(html, location:) extract_best_place_from_ddg_html(html, location: location) end
Source
# File plugins/beer.rb, line 491 def self.extract_review_from_ddg_html(html, place:) doc = Nokogiri::HTML(html.to_s) doc.css(".result").each do |result| title = result.at_css(".result__a")&.text&.strip.to_s snippet = result.at_css(".result__snippet")&.text&.strip next if snippet.nil? || snippet.empty? next if place && !place_matches_text?(place, title) && !place_matches_text?(place, snippet) review = extract_review_from_text(snippet) return review if review end nil end
Source
# File plugins/beer.rb, line 437 def self.extract_review_from_google_maps_ddg_html(html, place:) doc = Nokogiri::HTML(html.to_s) doc.css(".result").each do |result| title = result.at_css(".result__a")&.text&.strip.to_s href = Duck.clean_url(result.at_css(".result__a")&.[]("href")).to_s snippet = result.at_css(".result__snippet")&.text&.strip next if snippet.nil? || snippet.empty? next unless google_maps_source?(href, title) next unless place_matches_text?(place, title) || place_matches_text?(place, snippet) review = extract_google_maps_review_from_text(snippet) return review if review end nil end
Source
# File plugins/beer.rb, line 407 def self.extract_review_from_google_maps_html(html, place:) text = html.to_s return nil if text.empty? if (match = text.match(/window\.APP_INITIALIZATION_STATE\s*=\s*(\[.+?\]);window\./m)) begin strings = [] extract_strings_from_json(JSON.parse(match[1]), strings) strings.uniq! if strings.any? { |snippet| place_matches_text?(place, snippet) } strings.each do |snippet| review = extract_google_maps_review_from_text(snippet) return review if review end end rescue JSON::ParserError nil end end text.scan(/"([^"]{20,300})"/).flatten.each do |snippet| next unless place_matches_text?(place, snippet) review = extract_google_maps_review_from_text(snippet) return review if review end nil end
Source
# File plugins/beer.rb, line 357 def self.extract_review_from_text(text) text = text.to_s.strip return nil if text.empty? return nil if text.match?(AGGREGATE_REVIEW_PATTERN) if (match = text.match(/"([^"]{10,})"/)) quote = match[1].strip return quote unless quote.match?(AGGREGATE_REVIEW_PATTERN) end if text.match?(/review|rated|stars?|recommend/i) review = Duck.trim_snippet(text) return review unless review.empty? || review.match?(AGGREGATE_REVIEW_PATTERN) end nil end
Source
# File plugins/beer.rb, line 506 def self.extract_snippet_for_place(html, place_name) doc = Nokogiri::HTML(html.to_s) doc.css(".result").each do |result| title = result.at_css(".result__a")&.text&.strip.to_s next unless place_matches_text?(place_name, title) snippet = result.at_css(".result__snippet")&.text&.strip next if snippet.nil? || snippet.empty? cleaned = clean_facebook_snippet(snippet) return cleaned unless cleaned.empty? end nil end
Source
# File plugins/beer.rb, line 398 def self.extract_strings_from_json(value, strings) case value when Array value.each { |entry| extract_strings_from_json(entry, strings) } when String strings << value if value.length > 10 end end
Source
# File plugins/beer.rb, line 469 def self.find_google_maps_review(place, location, maps_url: nil, fetch:, fetch_page:) if maps_url && maps_url.include?("google.com/maps") html = fetch_page.call(maps_url) review = extract_review_from_google_maps_html(html, place: place) return review if review end google_maps_review_queries(place, location).each do |query| review = extract_review_from_google_maps_ddg_html(fetch.call(query), place: place) return review if review end nil rescue StandardError nil end
Source
# File plugins/beer.rb, line 522 def self.find_place(location, fetch:) place_search_queries(location).each do |query| html = fetch.call(query) candidate = extract_best_candidate_from_ddg_html(html, location: location) return [candidate, html] if candidate end nil end
Source
# File plugins/beer.rb, line 486 def self.find_review(place, location, maps_url: nil, fetch:, fetch_page:) find_google_maps_review(place, location, maps_url: maps_url, fetch: fetch, fetch_page: fetch_page) || extract_review_from_ddg_html(fetch.call(review_search_query(place, location)), place: place) end
Source
# File plugins/beer.rb, line 532 def self.format_announcement(place, location, snippet: nil, url: nil, review: nil, address: nil) message = "Grab a beer at #{place} near #{location}" message += ": #{snippet}" if snippet && !snippet.empty? message += ". Location: #{address}" if address && !address.empty? message += ". Review: \"#{review}\"" if review && !review.empty? message += " #{url}" if url && !url.empty? message end
Source
# File plugins/beer.rb, line 217 def self.google_maps_place_query(place, location) quoted_place = place.include?(" ") ? %("#{place}") : place %(#{quoted_place} #{location} site:google.com/maps/place) end
Source
# File plugins/beer.rb, line 208 def self.google_maps_review_queries(place, location) quoted_place = place.include?(" ") ? %("#{place}") : place [ %(#{quoted_place} #{location} reviews site:google.com/maps), %(#{quoted_place} #{location} site:google.com/maps/place), %(#{place} #{location} google maps review) ] end
Source
# File plugins/beer.rb, line 222 def self.google_maps_source?(href, title) href = href.to_s title = title.to_s href.include?("google.com/maps") || href.include?("google.com/travel/hotels") || title.match?(/Google\s+Maps/i) || title.match?(/Explore in Google Maps/i) end
Source
# File plugins/beer.rb, line 91 def self.google_places_query(location) "pub #{location} site:google.com/maps" end
Source
# File plugins/beer.rb, line 541 def self.lookup(location, fetch:, fetch_page: nil) fetch_page ||= ->(_url) { "" } place_info = find_place(location, fetch: fetch) return "Could not find a pub near #{location}" unless place_info candidate, html = place_info place = candidate[:name] url = candidate[:url] maps_url = url if url.to_s.include?("google.com/maps") maps_url ||= resolve_google_maps_url(place, location, fetch: fetch) snippet = extract_snippet_for_place(html, place) address = extract_address_from_text(snippet) || extract_address_from_ddg_html(fetch.call(address_search_query(place, location)), place: place) review = find_review(place, location, maps_url: maps_url, fetch: fetch, fetch_page: fetch_page) format_announcement(place, location, snippet: snippet, url: url, review: review, address: address) rescue StandardError "Could not find a pub near #{location}" end
Source
# File plugins/beer.rb, line 255 def self.normalize_place_text(text) text.to_s.downcase.gsub(/[^a-z0-9\s]/, " ").squeeze(" ").strip end
Source
# File plugins/beer.rb, line 69 def self.parse_location(text) text = text.to_s.strip return { error: usage_message } if text.empty? if text.match?(%r{\A["']}) unless (match = text.match(%r{\A["'](.+?)["']\z})) return { error: usage_message } end location = match[1].strip return { error: usage_message } if location.empty? return { location: location } end { location: text } end
Source
# File plugins/beer.rb, line 159 def self.place_candidate(title, href, location:) title = title.to_s.strip href = href.to_s return nil if title.empty? return nil if reject_listing_url?(href) name = if href.include?("facebook.com") extract_name_from_facebook_title(title) elsif href.include?("google.com/maps") || title.match?(/Google\s+Maps/i) extract_name_from_google_maps_title(title) || clean_place_title(title) else clean_place_title(title) end return nil if name.nil? || name.empty? return nil unless valid_place_name?(name) score = 0 score += 100 if href.include?("facebook.com") score += 90 if href.include?("google.com/maps") score += 60 if name.match?(BEER_VENUE_PATTERN) score += 30 if location.to_s.downcase.split.any? { |word| name.downcase.include?(word) } score += 20 if href.match?(BEER_VENUE_PATTERN) { name: name, score: score, url: Duck.clean_url(href) } end
Source
# File plugins/beer.rb, line 259 def self.place_matches_text?(place, text) norm_place = normalize_place_text(place) norm_text = normalize_place_text(text) return false if norm_place.empty? || norm_text.empty? return false if conflicting_venue_name?(place, text) return true if norm_text.include?(norm_place) words = norm_place.split.uniq distinctive = words.reject { |word| GENERIC_VENUE_WORDS.include?(word) || word.length < 3 } generic_in_place = words & GENERIC_VENUE_WORDS return false if distinctive.any? && !distinctive.all? { |word| norm_text.include?(word) } if generic_in_place.any? generic_in_place.any? { |word| norm_text.include?(word) } else words.count { |word| norm_text.include?(word) } >= [words.size, 2].min end end
Source
# File plugins/beer.rb, line 95 def self.place_search_queries(location) [ social_place_query(location), google_places_query(location), "pub #{location} queensland", "tavern #{location} queensland", "rsl #{location}", "bowls club #{location} bar" ] end
Source
# File plugins/beer.rb, line 106 def self.reject_listing_url?(href) href.to_s.match?(LISTING_URL_PATTERN) end
Source
# File plugins/beer.rb, line 454 def self.resolve_google_maps_url(place, location, fetch:) html = fetch.call(google_maps_place_query(place, location)) doc = Nokogiri::HTML(html.to_s) doc.css(".result").each do |result| title = result.at_css(".result__a")&.text&.strip.to_s href = Duck.clean_url(result.at_css(".result__a")&.[]("href")).to_s next unless google_maps_source?(href, title) next unless place_matches_text?(place, title) return href unless href.empty? end nil end
Source
# File plugins/beer.rb, line 204 def self.review_search_query(place, location) "#{place} #{location} review" end
Source
# File plugins/beer.rb, line 65 def self.usage_message 'Usage: !beer <location> — quote multi-word locations, e.g. !beer "mount low"' end
Source
# File plugins/beer.rb, line 315 def self.valid_address?(address) text = address.to_s.strip return false if text.empty? return false if text.match?(/\A\d{4}\z/) return false if text.match?(FACEBOOK_SNIPPET_NOISE_PATTERN) text.match?(/\b(?:Street|St|Road|Rd|Avenue|Ave|Drive|Dr|Parade|Pde|Way|Lane|Ln|Court|Ct)\b/i) || text.match?(/\A\d+\s+[A-Za-z]/) end
Source
# File plugins/beer.rb, line 110 def self.valid_place_name?(name) text = name.to_s.strip return false if text.empty? || text.length > 80 return false if text == "No results found" return false if text.match?(REJECT_PLACE_PATTERN) return false if text.match?(/#\w+|\d+\s*views|watch the latest videos/i) return false unless text.match?(BEER_VENUE_PATTERN) true end
Public Instance Methods
Source
# File plugins/beer.rb, line 576 def execute(m, args_text) parsed = self.class.parse_location(args_text) if parsed[:error] m.reply(parsed[:error]) return end safe_reply(m, lookup(parsed[:location])) end
Source
# File plugins/beer.rb, line 564 def fetch_html(query) url = "#{Duck::DDG_HTML_URL}?q=#{CGI.escape(query)}" URI.parse(url).open("User-Agent" => "Mozilla/5.0").read end
Source
# File plugins/beer.rb, line 569 def fetch_page(url) URI.parse(url).open( "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept-Language" => "en-AU,en;q=0.9" ).read end
Source
# File plugins/beer.rb, line 560 def lookup(location, fetch: method(:fetch_html), fetch_page: method(:fetch_page)) self.class.lookup(location, fetch: fetch, fetch_page: fetch_page) end