module BomForecast
BomForecast — BOM place resolution, HTML parsing, and forecast lookup.
Constants
- BASE_URL
- STATES
- USER_AGENT
Public Instance Methods
Source
# File lib/bom_forecast.rb, line 207 def best_matching_state(candidates, slug, fetch:) ranked = candidates.map { |state| [state, state_forecast_score(state, slug, fetch: fetch)] } _state, best_score = ranked.max_by { |_state, score| score } return nil unless best_score&.positive? ranked.select { |_state, score| score == best_score }.map(&:first).min_by { |state| STATES.index(state) } end
Source
# File lib/bom_forecast.rb, line 57 def build_forecast_url(state, slug) "#{BASE_URL}/places/#{state}/#{slug}/forecast/" end
Source
# File lib/bom_forecast.rb, line 181 def current_forecast_description(forecast_days) day = forecast_days.find { |entry| entry[:period].to_s.match?(/rest of/i) } return nil unless day description = day[:description] || day[:summary] description.to_s.strip.empty? ? nil : description end
Source
# File lib/bom_forecast.rb, line 299 def default_probe(_state, _slug, url) RabbotHttp.open(url, user_agent: USER_AGENT) do |io| io.status[0] == "200" end rescue OpenURI::HTTPError false rescue StandardError false end
Source
# File lib/bom_forecast.rb, line 252 def fetch_api_forecast(location, state:, fetch:) require_relative "bom_location_api" unless defined?(BomLocationApi) require_relative "bom_forecast_api" unless defined?(BomForecastApi) api_place = BomLocationApi.resolve(location, state: state, fetch: fetch) return nil unless api_place forecast = BomForecastApi.lookup(api_place, fetch: fetch) return nil unless forecast { forecast: forecast, api_place: api_place } end
Source
# File lib/bom_forecast.rb, line 86 def fetch_current_conditions(forecast_html, fetch:) obs_path = parse_observations_path(forecast_html) return nil unless obs_path parse_observations(fetch.call(observations_url(obs_path))) rescue StandardError nil end
Source
# File lib/bom_forecast.rb, line 309 def fetch_forecast(location, fetch: nil, probe: method(:default_probe)) fetcher = if fetch fetch else ->(url) { RabbotHttp.fetch(url, user_agent: USER_AGENT) } end place = resolve_place(location, probe: probe, fetch: fetcher) return nil unless place legacy = fetch_legacy_forecast(place, fetch: fetcher) return legacy[:forecast] if legacy api_result = fetch_api_forecast(location, state: place[:state], fetch: fetcher) api_result&.dig(:forecast) end
Source
# File lib/bom_forecast.rb, line 237 def fetch_legacy_forecast(place, fetch:) forecast_url = build_forecast_url(place[:state], place[:slug]) forecast_html = fetch.call(forecast_url) forecast = parse_forecast(forecast_html) return nil unless forecast { forecast: forecast, html: forecast_html } rescue OpenURI::HTTPError nil rescue RuntimeError => e raise unless e.message.to_s.match?(/redirection forbidden/i) nil end
Source
# File lib/bom_forecast.rb, line 165 def filter_forecast_days(forecast_days, days: 1) if days.to_i == 6 forecast_days.first(6) else forecast_days.reject { |day| day[:period].to_s.match?(/rest of/i) }.first(1) end end
Source
# File lib/bom_forecast.rb, line 65 def find_state(slug, state: nil, probe:) return state if state && STATES.include?(state) STATES.each do |candidate| url = build_forecast_url(candidate, slug) return candidate if probe.call(candidate, slug, url) end nil end
Source
# File lib/bom_forecast.rb, line 265 def lookup(location, fetch:, probe: method(:default_probe), days: nil) command = parse_command(location) days = command[:days] if days.nil? && command.key?(:days) location = command[:location] place = resolve_place(location, probe: probe, fetch: fetch) return "Location not found" unless place legacy = fetch_legacy_forecast(place, fetch: fetch) forecast = legacy&.dig(:forecast) api_place = nil unless forecast api_result = fetch_api_forecast(location, state: place[:state], fetch: fetch) return "Location not found" unless api_result forecast = api_result[:forecast] api_place = api_result[:api_place] end return "Forecast not found" unless forecast current = if legacy fetch_current_conditions(legacy[:html], fetch: fetch) elsif api_place require_relative "bom_observations_api" unless defined?(BomObservationsApi) BomObservationsApi.fetch_latest(api_place[:station_id], fetch: fetch) end require_relative "bom_forecast_report" unless defined?(BomForecastReport) BomForecastReport.format_report(forecast, current: current, days: days, state: place[:state]) rescue StandardError "Weather lookup failed" end
Source
# File lib/bom_forecast.rb, line 189 def matching_states(slug, state: nil, probe:) return [state] if state && STATES.include?(state) STATES.select { |candidate| probe.call(candidate, slug, build_forecast_url(candidate, slug)) } end
Source
# File lib/bom_forecast.rb, line 61 def normalize_value(text) text.to_s.strip.gsub(/\s+/, " ").gsub("° C", "°C").gsub("°C", " °C").gsub(" °C", " °C").strip end
Source
# File lib/bom_forecast.rb, line 82 def observations_url(obs_path) obs_path.start_with?("http") ? obs_path : "#{BASE_URL}#{obs_path}" end
Source
# File lib/bom_forecast.rb, line 24 def parse_command(text) args = text.to_s.strip return { action: :forecast, days: 1, location: "" } if args.empty? parts = args.split(/\s+/) if parts[0].casecmp("poker").zero? require_relative "bom_poker" parsed = BomPoker.parse_poker_args(parts[1..]) return parsed if parsed[:error] { action: :poker, **parsed } elsif BomEasterEgg.pearl_harbour_easter_egg?(args) { action: :trout_slap } elsif (match = args.match(/\A6\s+(.+)\z/i)) { action: :forecast, days: 6, location: match[1] } else { action: :forecast, days: 1, location: args } end end
Source
# File lib/bom_forecast.rb, line 154 def parse_forecast(html) doc = Nokogiri::HTML(html.to_s) place = doc.at_css("h1")&.text.to_s.sub(/\s+Forecast.*\z/i, "").strip issued = doc.at_css("p.date")&.text.to_s.strip days = doc.css("div.forecastPage div.day").filter_map { |day| parse_forecast_day(day) } return nil if place.empty? || days.empty? { place: place, issued: issued, days: days } end
Source
# File lib/bom_forecast.rb, line 130 def parse_forecast_day(day_node) forecast_node = day_node.at_css("div.forecast") return nil unless forecast_node condition = forecast_node.at_css("dd.image img")&.[]("alt").to_s.strip min = normalize_value(forecast_node.at_css("dd.min")&.text) max = normalize_value(forecast_node.at_css("dd.max")&.text) rainfall = normalize_value(forecast_node.at_css("dd.amt")&.text) rain_chance = forecast_node.at_css("dd.pop")&.text.to_s.gsub(/\s+/, " ").strip rain_chance = rain_chance.sub(/\s+<.*\z/m, "").strip description = parse_forecast_description(forecast_node) { period: day_node.at_css("h2")&.text.to_s.strip, condition: condition.empty? ? nil : condition, min: min.empty? ? nil : min, max: max.empty? ? nil : max, rainfall: rainfall.empty? ? nil : rainfall, rain_chance: rain_chance.empty? ? nil : rain_chance, description: description, summary: description } end
Source
# File lib/bom_forecast.rb, line 122 def parse_forecast_description(forecast_node) text = forecast_node.at_css("p")&.text.to_s.strip.gsub(/\s+/, " ") return text unless text.empty? precis = normalize_value(forecast_node.at_css("dd.summary")&.text) precis.empty? ? nil : precis end
Source
# File lib/bom_forecast.rb, line 44 def parse_location(location) text = location.to_s.strip words = text.split(/\s+/) state = nil if words.length > 1 && STATES.include?(words.first.downcase) state = words.first.downcase text = words.drop(1).join(" ") end { state: state, slug: slugify_location(text) } end
Source
# File lib/bom_forecast.rb, line 102 def parse_observations(html) doc = Nokogiri::HTML(html.to_s) summary = doc.at_css("div.obs-summary") return nil unless summary temperature = normalize_value(summary.at("p")&.text.to_s.sub(/\ACurrent Temperature\s*/i, "")) observed_at = summary.at("h2 span")&.text.to_s.strip { temperature: temperature, observed_at: observed_at, humidity: table_value(summary, "summary", "Humidity"), feels_like: table_value(summary, "summary", "Feels like"), pressure: table_value(summary, "summary", "Pressure"), wind_direction: table_value(summary, "wind", "Wind Direction"), wind_speed: table_value(summary, "wind", "Wind Speed"), wind_gust: table_value(summary, "wind", "Wind Gust") } end
Source
# File lib/bom_forecast.rb, line 76 def parse_observations_path(html) doc = Nokogiri::HTML(html.to_s) href = doc.at_css("div.right-column li.obs a[href]")&.[]("href") href.to_s.strip.empty? ? nil : href end
Source
# File lib/bom_forecast.rb, line 173 def rain_forecast_days(forecast_days, days: 1) if days.to_i == 6 forecast_days.first(6) else forecast_days end end
Source
# File lib/bom_forecast.rb, line 215 def resolve_place(location, probe:, fetch: nil) parsed = parse_location(location) return nil if parsed[:slug].empty? if parsed[:state] && STATES.include?(parsed[:state]) return { state: parsed[:state], slug: parsed[:slug] } end matches = matching_states(parsed[:slug], state: parsed[:state], probe: probe) return nil if matches.empty? state = if matches.length == 1 matches.first elsif fetch best_matching_state(matches, parsed[:slug], fetch: fetch) || matches.first else matches.first end { state: state, slug: parsed[:slug] } end
Source
# File lib/bom_forecast.rb, line 16 def slugify_location(location) location.to_s .downcase .gsub(/[^a-z0-9]+/, "-") .gsub(/-+/, "-") .gsub(/\A-|-\z/, "") end
Source
# File lib/bom_forecast.rb, line 195 def state_forecast_score(state, slug, fetch:) forecast_html = fetch.call(build_forecast_url(state, slug)) forecast = parse_forecast(forecast_html) return 0 unless forecast score = 1 score += 1 if fetch_current_conditions(forecast_html, fetch: fetch) score rescue StandardError 0 end
Source
# File lib/bom_forecast.rb, line 95 def table_value(doc, table_class, label) row = doc.at_xpath("//table[contains(@class, '#{table_class}')]//tr[th[contains(normalize-space(.), '#{label}')]]") cell = row&.at("td") cell&.css("span.knots")&.remove normalize_value(cell&.text) end