module AthfEpisodeIndex
Constants
- API_URL
- BASE_URL
- CACHE_KEY
- CATEGORY_TITLE
- DEFAULT_TTL_SEC
- PLUGIN_KEY
- SEARCH_MIN_SCORE
- SOURCE_URL
- STOP_WORDS
- SUMMARY_MAX_CHARS
Public Instance Methods
Source
# File lib/athf_episode_index.rb, line 24 def api_url(params) "#{API_URL}?#{URI.encode_www_form(params)}" end
Source
# File lib/athf_episode_index.rb, line 158 def cached_or_fetch(fetch: method(:default_fetch), now: Time.now, ttl_sec: DEFAULT_TTL_SEC, store: RabbotDb) cached = load_cache(store: store) return cached if cached && !stale?(cached, now: now, ttl_sec: ttl_sec) fresh = fetch_remote(fetch: fetch, now: now) return save_cache(fresh, store: store) if fresh["episodes"].any? cached || fresh rescue StandardError cached || { "fetched_at" => now.utc.iso8601, "source_url" => SOURCE_URL, "episodes" => [] } end
Source
# File lib/athf_episode_index.rb, line 28 def category_api_url(continue_token: nil) params = { action: "query", generator: "categorymembers", gcmtitle: CATEGORY_TITLE, gcmtype: "page", gcmlimit: "max", prop: "extracts|info", inprop: "url", explaintext: "1", redirects: "1", format: "json", utf8: "1" } params[:gcmcontinue] = continue_token if continue_token api_url(params) end
Source
# File lib/athf_episode_index.rb, line 46 def default_fetch(url) RabbotHttp.fetch(url, user_agent: RabbotHttp::DEFAULT_USER_AGENT) end
Source
# File lib/athf_episode_index.rb, line 244 def episode_code(episode) season = episode["season"] ep = episode["episode"] return nil unless season && ep format("S%02dE%02d", season, ep) end
Source
# File lib/athf_episode_index.rb, line 170 def episodes(fetch: method(:default_fetch), now: Time.now, ttl_sec: DEFAULT_TTL_SEC, store: RabbotDb) cached_or_fetch(fetch: fetch, now: now, ttl_sec: ttl_sec, store: store).fetch("episodes", []) end
Source
# File lib/athf_episode_index.rb, line 94 def extract_metadata(text) text = text.to_s season, episode = if (match = text.match(/\bSeason\s+(\d+)\s*,?\s*Episode\s+(\d+)\b/i)) [match[1].to_i, match[2].to_i] elsif (match = text.match(/\bS(?:eason)?\s*(\d+)\s*E(?:pisode)?\s*(\d+)\b/i)) [match[1].to_i, match[2].to_i] end overall = if (match = text.match(/\b(?:and\s+)?(?:the\s+)?(\d+)(?:st|nd|rd|th)\s+episode\b/i)) match[1].to_i elsif (match = text.match(/\b#\s*(\d+)\b/)) match[1].to_i end air_date = text.match(/\b(?:aired(?:\s+on)?|air date)[^A-Za-z0-9]+([A-Z][a-z]+\s+\d{1,2},\s+\d{4})/i)&.[](1) { season: season, episode: episode, overall: overall, air_date: air_date } end
Source
# File lib/athf_episode_index.rb, line 110 def fetch_remote(fetch: method(:default_fetch), now: Time.now) episodes = [] continue_token = nil loop do body = fetch.call(category_api_url(continue_token: continue_token)) episodes.concat(parse_pages(body)) continue_token = next_continue_token(body) break if continue_token.nil? || continue_token.empty? end { "fetched_at" => now.utc.iso8601, "source_url" => SOURCE_URL, "episodes" => sort_episodes(episodes) } end
Source
# File lib/athf_episode_index.rb, line 206 def find(query, episodes:, random: Random) query = query.to_s.strip return random_episode(episodes, random: random) if query.empty? if numeric_query?(query) target = query.to_i return episodes.find { |episode| episode["overall"].to_i == target } || episodes.find { |episode| episode["episode"].to_i == target } end if (match = season_episode_query(query)) season = match[1].to_i episode_number = match[2].to_i return episodes.find { |episode| episode["season"].to_i == season && episode["episode"].to_i == episode_number } end episodes .map { |episode| [episode, score_episode(episode, query)] } .select { |_episode, score| score >= SEARCH_MIN_SCORE } .max_by { |_episode, score| score } &.first end
Source
# File lib/athf_episode_index.rb, line 252 def format_episode(episode) return "No episode found" unless episode meta = [episode_code(episode), episode["overall"] ? "##{episode["overall"]}" : nil, episode["air_date"]].compact.join(" / ") parts = ["[ATHF] #{episode["title"]}"] parts << meta unless meta.empty? parts << summary(episode["extract"]) line = parts.reject(&:empty?).join(" - ") url = episode["url"].to_s.strip url.empty? ? line : "#{line} (#{url})" end
Source
# File lib/athf_episode_index.rb, line 138 def load_cache(store: RabbotDb) payload = store.get_plugin_json(plugin: PLUGIN_KEY, key: CACHE_KEY, default: nil) return nil unless payload.is_a?(Hash) payload end
Source
# File lib/athf_episode_index.rb, line 264 def lookup(query = nil, fetch: method(:default_fetch), now: Time.now, ttl_sec: DEFAULT_TTL_SEC, random: Random, store: RabbotDb) episode = find(query.to_s, episodes: episodes(fetch: fetch, now: now, ttl_sec: ttl_sec, store: store), random: random) format_episode(episode) end
Source
# File lib/athf_episode_index.rb, line 68 def next_continue_token(json) data = json.is_a?(String) ? JSON.parse(json) : json data.dig("continue", "gcmcontinue") rescue JSON::ParserError nil end
Source
# File lib/athf_episode_index.rb, line 75 def normalize_episode(page) title = page["title"].to_s.strip return nil if title.empty? || title.start_with?("Category:") extract = normalize_extract(page["extract"]) metadata = extract_metadata("#{title} #{extract}") { "title" => title, "pageid" => page["pageid"], "url" => page["fullurl"].to_s, "extract" => extract, "season" => metadata[:season], "episode" => metadata[:episode], "overall" => metadata[:overall], "air_date" => metadata[:air_date], "transcript_status" => "not_fetched" } end
Source
# File lib/athf_episode_index.rb, line 50 def normalize_extract(text) text.to_s.gsub(/\s+/, " ").strip end
Source
# File lib/athf_episode_index.rb, line 174 def normalize_text(text) text.to_s.downcase.gsub(/[^a-z0-9\s]/, " ").squeeze(" ").strip end
Source
# File lib/athf_episode_index.rb, line 182 def numeric_query?(query) query.to_s.strip.match?(/\A\d+\z/) end
Source
# File lib/athf_episode_index.rb, line 54 def parse_pages(json) data = json.is_a?(String) ? JSON.parse(json) : json pages = data.dig("query", "pages") return [] unless pages.is_a?(Hash) pages.values.filter_map do |page| next if page["missing"] normalize_episode(page) end rescue JSON::ParserError [] end
Source
# File lib/athf_episode_index.rb, line 178 def query_tokens(query) normalize_text(query).split.reject { |token| token.length < 2 || STOP_WORDS.include?(token) } end
Source
# File lib/athf_episode_index.rb, line 229 def random_episode(episodes, random: Random) list = Array(episodes) return nil if list.empty? list.sample(random: random) end
Source
# File lib/athf_episode_index.rb, line 145 def save_cache(payload, store: RabbotDb) store.set_plugin_json(plugin: PLUGIN_KEY, key: CACHE_KEY, value: payload) payload end
Source
# File lib/athf_episode_index.rb, line 190 def score_episode(episode, query) tokens = query_tokens(query) return 0 if tokens.empty? title = normalize_text(episode["title"]) body = normalize_text("#{episode["title"]} #{episode["extract"]}") score = 0 score += 120 if title == normalize_text(query) score += 80 if title.include?(normalize_text(query)) tokens.each do |token| score += 35 if title.include?(token) score += 8 if body.include?(token) end score end
Source
# File lib/athf_episode_index.rb, line 186 def season_episode_query(query) query.to_s.strip.match(/\A(?:s)?(\d{1,2})\s*(?:x|e)\s*(\d{1,2})\z/i) end
Source
# File lib/athf_episode_index.rb, line 127 def sort_episodes(episodes) episodes.sort_by do |episode| [ episode["overall"] || 10_000, episode["season"] || 10_000, episode["episode"] || 10_000, episode["title"].to_s.downcase ] end end
Source
# File lib/athf_episode_index.rb, line 150 def stale?(payload, now: Time.now, ttl_sec: DEFAULT_TTL_SEC) return true unless payload.is_a?(Hash) fetched_at = Time.parse(payload["fetched_at"].to_s) now - fetched_at > ttl_sec rescue ArgumentError, TypeError true end
Source
# File lib/athf_episode_index.rb, line 236 def summary(extract, max_chars: SUMMARY_MAX_CHARS) text = normalize_extract(extract) return "" if text.empty? return text if text.length <= max_chars "#{text[0, max_chars].sub(/\s+\S*\z/, "")}..." end