class Pies
Constants
- AGENT_MODEL
- AU_STATE_ABBREV
- AU_STATE_FULL_NAMES
- BAKERY_VENUE_PATTERN
- FACEBOOK_IMAGE_SKIP_PATTERN
- FACEBOOK_SNIPPET_NOISE_PATTERN
- LISTING_URL_PATTERN
- MAX_MENU_IMAGES
- MAX_MENU_ITEMS
- MENU_IMAGE_CACHE_PLUGIN
- MENU_IMAGE_CACHE_PREFIX
- MENU_PARAGRAPH_MAX_BYTES
- PIE_ITEM_PATTERN
- REJECT_PLACE_PATTERN
- REVERSE_GEOCODE_ZOOM
- STYLE_TITLE
- USER_AGENT
Public Class Methods
Source
# File plugins/pies.rb, line 264 def self.address_field(place, *keys) data = place.data if place.respond_to?(:data) return nil unless data.is_a?(Hash) addr = data["address"] || data[:address] || {} keys.each do |key| value = addr[key] || addr[key.to_sym] return value.to_s.strip unless value.to_s.strip.empty? end nil end
Source
# File plugins/pies.rb, line 301 def self.area_search_locations(resolved, search: Geocoder.method(:search)) locations = [] primary = resolved[:search_query].to_s.strip locations << primary unless primary.empty? place = resolved[:place] return locations unless place suburb = place_name_from_result(place) if suburb && !suburb.empty? && suburb != primary locations << suburb unless locations.include?(suburb) end state = state_abbrev_from_result(place) parent = parent_area_label(place, search: search) locations << parent if parent && !locations.include?(parent) postcode = postcode_from_place(place) if postcode && state postcode_label = "#{postcode} #{state}" locations << postcode_label unless locations.include?(postcode_label) end locations end
Source
# File plugins/pies.rb, line 432 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?(/\bpie/i) text = text.sub(/\d[\d,]*\s+likes?\s*[·•].*$/i, "").strip text = text.sub(/\d+\s+talking\s+about\s+this.*$/i, "").strip Duck.trim_snippet(text) end
Source
# File plugins/pies.rb, line 350 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/pies.rb, line 180 def self.compact_search_label(place, label) name = place_name_from_result(place) state = state_abbrev_from_result(place) if name && state return "#{name} #{state}" elsif name return name end parts = label.to_s.split(",").map(&:strip).reject(&:empty?) return parts.first if parts.length <= 1 town = parts.first abbrev = AU_STATE_ABBREV[parts[1].to_s.downcase] abbrev ? "#{town} #{abbrev}" : "#{town} #{parts[1]}" end
Source
# File plugins/pies.rb, line 408 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/pies.rb, line 442 def self.extract_facebook_description(html) doc = Nokogiri::HTML(html.to_s) raw = doc.at_css('meta[property="og:description"]')&.[]("content") || doc.at_css('meta[name="description"]')&.[]("content") return nil if raw.nil? || raw.strip.empty? text = CGI.unescapeHTML(raw.to_s) text = text.sub(/\A[^.]+\.\s*/, "").strip if text.match?(/\d[\d,]*\s+likes?/i) text = text.sub(/\d[\d,]*\s+likes?\s*[·•][^.]*\.\s*/i, "").strip text = text.sub(/\d+\s+talking\s+about\s+this\s*[·•][^.]*\.\s*/i, "").strip text = text.sub(/\d[\d,]*\s+were\s+here\s*[·•]?\s*/i, "").strip cleaned = Duck.trim_snippet(text) cleaned.empty? ? nil : cleaned end
Source
# File plugins/pies.rb, line 457 def self.extract_facebook_image_urls(html) doc = Nokogiri::HTML(html.to_s) urls = [] og = doc.at_css('meta[property="og:image"]')&.[]("content") cleaned_og = Duck.clean_url(og) urls << cleaned_og unless cleaned_og.empty? doc.css('img[src], img[data-src], link[rel="preload"][as="image"]').each do |node| src = node["src"] || node["href"] || node["data-src"] next if src.nil? || src.strip.empty? cleaned = Duck.clean_url(src) next if cleaned.empty? next if cleaned.match?(FACEBOOK_IMAGE_SKIP_PATTERN) urls << cleaned unless urls.include?(cleaned) end urls.first(MAX_MENU_IMAGES) end
Source
# File plugins/pies.rb, line 364 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 if (match = title.match(/\A(.+?)\s+-\s+Facebook\z/i)) return clean_place_title(match[1]) end nil end
Source
# File plugins/pies.rb, line 577 def self.find_place(location, fetch:, extra_queries: [], state_name: "queensland") (place_search_queries(location, state_name: state_name) + extra_queries).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/pies.rb, line 587 def self.find_place_across_areas(resolved, fetch:) suburb = place_name_from_result(resolved[:place]) state_name = state_name_from_place(resolved[:place])&.downcase || "queensland" area_search_locations(resolved).each_with_index do |location, index| extra = index.zero? ? [] : wider_place_search_queries(suburb, location, state_name: state_name) place_info = find_place(location, fetch: fetch, extra_queries: extra, state_name: state_name) next unless place_info return { place_info: place_info, search_location: location, wider_area: index.positive? ? location : nil } end nil end
Source
# File plugins/pies.rb, line 692 def self.format_not_found(location) header = IrcFormat.report_header(tag: "PIES", title: location.to_s.strip, style: STYLE_TITLE) [ header, "Could not find a bakery near #{location}.", IrcFormat.report_footer("pies lookup") ].join("\n") end
Source
# File plugins/pies.rb, line 674 def self.format_report(place:, location:, menu_items:, description:, url:, wider_area: nil) header = IrcFormat.report_header(tag: "PIES", title: banner_title(place, location), style: STYLE_TITLE) body_lines = if menu_items.any? format_menu_paragraphs(menu_items) elsif description && !description.empty? [description] else ["No pie menu found — check the bakery page for today's pies."] end lines = [header, *body_lines] lines << "Nearest bakery is in the #{wider_area} area." if wider_area && !wider_area.to_s.strip.empty? lines << url.to_s.strip unless url.to_s.strip.empty? footer_location = location.to_s.split(",").first.to_s.strip lines << IrcFormat.report_footer("bakery", footer_location, "Facebook") lines.join("\n") end
Source
# File plugins/pies.rb, line 701 def self.lookup(location, fetch:, fetch_page: nil, fetch_image: nil, runner: nil, search: Geocoder.method(:search), store: RabbotDb) fetch_page ||= ->(_url) { "" } resolved = resolve_location(location, search: search) label = resolved[:label] match = find_place_across_areas(resolved, fetch: fetch) return format_not_found(label) unless match place_info = match[:place_info] candidate, _html = place_info place = candidate[:name] url = candidate[:url] search_location = match[:search_location] menu = find_menu( place, search_location, facebook_url: url, fetch: fetch, fetch_page: fetch_page, fetch_image: fetch_image, runner: runner, store: store ) format_report( place: place, location: label, menu_items: menu[:items], description: menu[:description], url: url, wider_area: match[:wider_area] ) rescue StandardError format_not_found(location) end
Source
# File plugins/pies.rb, line 419 def self.normalize_place_text(text) text.to_s.downcase.gsub(/[^a-z0-9\s]/, " ").squeeze(" ").strip end
Source
# File plugins/pies.rb, line 281 def self.parent_area_label(place, search: Geocoder.method(:search)) coords = GeoLookup.coordinates_for(place) return nil unless coords results = search.call(coords, params: { zoom: REVERSE_GEOCODE_ZOOM }) parent = results.first return nil unless parent parent_name = place_name_from_result(parent) return nil if parent_name.nil? || parent_name.empty? suburb = place_name_from_result(place) return nil if suburb && parent_name.casecmp?(suburb) state = state_abbrev_from_result(parent) || state_abbrev_from_result(place) state ? "#{parent_name} #{state}" : parent_name rescue StandardError nil end
Source
# File plugins/pies.rb, line 102 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/pies.rb, line 384 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) 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 += 80 if href.include?("google.com/maps") score += 60 if name.match?(BAKERY_VENUE_PATTERN) score += 30 if location.to_s.downcase.split.any? { |word| word.length >= 3 && name.downcase.include?(word) } score += 20 if href.match?(BAKERY_VENUE_PATTERN) { name: name, score: score, url: Duck.clean_url(href) } end
Source
# File plugins/pies.rb, line 423 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? norm_text.include?(norm_place) || norm_place.split.count { |word| word.length >= 3 && norm_text.include?(word) } >= 2 end
Source
# File plugins/pies.rb, line 142 def self.place_name_from_result(place) return place.city.to_s.strip if place.respond_to?(:city) && !place.city.to_s.strip.empty? data = place.data if place.respond_to?(:data) if data.is_a?(Hash) && !data["name"].to_s.strip.empty? return data["name"].to_s.strip end if data.is_a?(Hash) addr = data["address"] || data[:address] || {} %w[city town village city_district hamlet suburb].each do |key| value = addr[key] || addr[key.to_sym] return value.to_s.strip unless value.to_s.strip.empty? end end nil end
Source
# File plugins/pies.rb, line 233 def self.place_search_queries(location, state_name: "queensland") state = state_name.to_s.downcase [ "#{location} bakery facebook", "#{location} pie shop facebook", "bakery #{location} #{state}", "pie shop #{location}", "bakehouse #{location}" ] end
Source
# File plugins/pies.rb, line 277 def self.postcode_from_place(place) address_field(place, "postcode") end
Source
# File plugins/pies.rb, line 335 def self.reject_listing_url?(href) href.to_s.match?(LISTING_URL_PATTERN) end
Source
# File plugins/pies.rb, line 218 def self.resolve_location(location, search: Geocoder.method(:search)) text = location.to_s.strip return { query: text, label: text, search_query: text, resolved: false } if text.empty? place = GeoLookup.lookup_place(text, search: search) if place label = resolved_label(place) search_query = compact_search_label(place, label) search_query = label if search_query.empty? { query: label, label: label, search_query: search_query, resolved: true, place: place } else { query: text, label: text, search_query: text, resolved: false } end end
Source
# File plugins/pies.rb, line 207 def self.resolved_label(place) data = place.data if place.respond_to?(:data) if data.is_a?(Hash) display = data["display_name"] || data[:display_name] text = display.to_s.strip return text unless text.empty? end GeoLookup.location_label(place) end
Source
# File plugins/pies.rb, line 161 def self.state_abbrev_from_result(place) if place.respond_to?(:state_code) code = place.state_code.to_s.strip return code.upcase if code.match?(/\A[A-Z]{2,3}\z/i) abbrev = AU_STATE_ABBREV[code.downcase] return abbrev if abbrev end data = place.data if place.respond_to?(:data) if data.is_a?(Hash) addr = data["address"] || data[:address] || {} iso = addr["ISO3166-2-lvl4"] || addr[:"ISO3166-2-lvl4"] return iso.to_s.split("-").last if iso.to_s.include?("-") end nil end
Source
# File plugins/pies.rb, line 198 def self.state_name_from_abbrev(abbrev) AU_STATE_FULL_NAMES[abbrev.to_s.upcase] end
Source
# File plugins/pies.rb, line 202 def self.state_name_from_place(place) abbrev = state_abbrev_from_result(place) state_name_from_abbrev(abbrev) end
Source
# File plugins/pies.rb, line 98 def self.usage_message 'Usage: !pies <location> — quote multi-word locations, e.g. !pies "blackbutt qld"' end
Source
# File plugins/pies.rb, line 339 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?(BAKERY_VENUE_PATTERN) true end
Source
# File plugins/pies.rb, line 244 def self.wider_place_search_queries(suburb, wider_area, state_name: "queensland") suburb = suburb.to_s.strip area = wider_area.to_s.strip state = state_name.to_s.downcase return [] if area.empty? queries = [ "bakery #{area} #{state}", "#{area} bakery facebook" ] if !suburb.empty? && !area.downcase.include?(suburb.downcase) queries.unshift( "bakery near #{suburb} #{area}", "#{suburb} bakery #{area} #{state}", "#{suburb} pie shop facebook" ) end queries end
Public Instance Methods
Source
# File plugins/pies.rb, line 766 def execute(m, args_text) parsed = self.class.parse_location(args_text) if parsed[:error] m.reply(parsed[:error]) return end flood_safe_reply(m, lookup(parsed[:location])) end
Source
# File plugins/pies.rb, line 750 def fetch_html(query) url = "#{Duck::DDG_HTML_URL}?q=#{CGI.escape(query)}" RabbotHttp.fetch(url, user_agent: USER_AGENT) end
Source
# File plugins/pies.rb, line 755 def fetch_page(url) RabbotHttp.fetch(url, user_agent: USER_AGENT) end
Source
# File plugins/pies.rb, line 737 def lookup(location, fetch: method(:fetch_html), fetch_page: method(:fetch_page), fetch_image: method(:fetch_menu_image), runner: AiAgent.method(:default_runner), search: Geocoder.method(:search), store: RabbotDb) self.class.lookup( location, fetch: fetch, fetch_page: fetch_page, fetch_image: fetch_image, runner: runner, search: search, store: store ) end