# frozen_string_literal: true module Granblue module Parsers # WikiDataParser extracts structured data from wiki text # for use in batch entity import flows class WikiDataParser # Parse character wiki text into suggestion fields def self.parse_character(wiki_text) return {} if wiki_text.blank? data = parse_wiki_text(wiki_text) suggestions = {} suggestions[:name_en] = data['name'] if data['name'].present? suggestions[:name_jp] = data['jpname'] if data['jpname'].present? suggestions[:granblue_id] = data['id'] if data['id'].present? # Character ID (for linking related characters) if data['charid'].present? char_ids = data['charid'].scan(/\b\d{4}\b/) suggestions[:character_id] = char_ids if char_ids.any? end # Rarity suggestions[:rarity] = Wiki.rarities[data['rarity']] if data['rarity'].present? # Element - capitalize first letter for case-insensitive lookup if data['element'].present? element_key = data['element'].strip.capitalize suggestions[:element] = Wiki.elements[element_key] end # Gender suggestions[:gender] = Wiki.genders[data['gender']] if data['gender'].present? # Proficiencies if data['weapon'].present? profs = data['weapon'].split(',').map(&:strip) suggestions[:proficiency1] = Wiki.proficiencies[profs[0]] if profs[0] suggestions[:proficiency2] = Wiki.proficiencies[profs[1]] if profs[1] end # Races if data['race'].present? races = data['race'].split(',').map(&:strip) suggestions[:race1] = Wiki.races[races[0]] if races[0] suggestions[:race2] = Wiki.races[races[1]] if races[1] end # Stats suggestions[:min_hp] = data['min_hp'].to_i if data['min_hp'].present? suggestions[:max_hp] = data['max_hp'].to_i if data['max_hp'].present? suggestions[:max_hp_flb] = data['flb_hp'].to_i if data['flb_hp'].present? suggestions[:min_atk] = data['min_atk'].to_i if data['min_atk'].present? suggestions[:max_atk] = data['max_atk'].to_i if data['max_atk'].present? suggestions[:max_atk_flb] = data['flb_atk'].to_i if data['flb_atk'].present? # Uncap status - characters use max_evo (5=FLB, 6=ULB) if data['max_evo'].present? evo = data['max_evo'].to_i suggestions[:flb] = evo >= 5 suggestions[:ulb] = evo >= 6 end # Fallback to legacy 5star field if max_evo not present suggestions[:flb] ||= Wiki.boolean.fetch(data['5star'], false) if data['5star'].present? # Series - character series like "grand", "zodiac", etc. # The |series= field can be comma-separated (e.g., "evoker,summer") if data['series'].present? series_values = data['series'].to_s.downcase.split(',').map(&:strip) series = series_values.map { |s| Wiki.character_series[s] }.compact suggestions[:series] = series.uniq.sort if series.any? end # Dates suggestions[:release_date] = parse_date(data['release_date']) if data['release_date'].present? suggestions[:flb_date] = parse_date(data['5star_date']) if data['5star_date'].present? suggestions[:ulb_date] = parse_date(data['6star_date']) if data['6star_date'].present? # External links - parse URLs to extract values suggestions[:gamewith] = parse_gamewith_url(data['link_gamewith']) if data['link_gamewith'].present? suggestions[:kamigame] = parse_kamigame_url(data['link_kamigame'], :character) if data['link_kamigame'].present? # Gacha fields - parse from obtain and series fields obtain = data['obtain'].to_s.downcase wiki_series = data['series'].to_s.downcase.strip # Season (from series field) suggestions[:season] = character_season_from_series(wiki_series, obtain) # Promotions (from obtain and series fields) promotions = character_promotions_from_obtain(obtain, wiki_series) suggestions[:promotions] = promotions if promotions.any? suggestions.compact end # Parse weapon wiki text into suggestion fields def self.parse_weapon(wiki_text) return {} if wiki_text.blank? data = parse_wiki_text(wiki_text) suggestions = {} suggestions[:name_en] = data['name'] if data['name'].present? suggestions[:name_jp] = data['jpname'] if data['jpname'].present? # Strip _note suffix from null element weapon IDs suggestions[:granblue_id] = data['id'].sub(/_note\z/, '') if data['id'].present? # Rarity suggestions[:rarity] = Wiki.rarities[data['rarity']] if data['rarity'].present? # Element - capitalize first letter for case-insensitive lookup if data['element'].present? element_key = data['element'].strip.capitalize suggestions[:element] = Wiki.elements[element_key] end # Proficiency (weapon type) - wiki uses |weapon= field, not |type= if data['weapon'].present? proficiency_key = data['weapon'].strip.capitalize suggestions[:proficiency] = Wiki.proficiencies[proficiency_key] end # Stats - weapons use hp1/hp2/hp3/hp4 and atk1/atk2/atk3/atk4 suggestions[:min_hp] = data['hp1'].to_i if data['hp1'].present? suggestions[:max_hp] = data['hp2'].to_i if data['hp2'].present? suggestions[:max_hp_flb] = data['hp3'].to_i if data['hp3'].present? suggestions[:max_hp_ulb] = data['hp4'].to_i if data['hp4'].present? suggestions[:min_atk] = data['atk1'].to_i if data['atk1'].present? suggestions[:max_atk] = data['atk2'].to_i if data['atk2'].present? suggestions[:max_atk_flb] = data['atk3'].to_i if data['atk3'].present? suggestions[:max_atk_ulb] = data['atk4'].to_i if data['atk4'].present? # Uncap status - weapons use evo_max (4=FLB, 5=ULB, 6=transcendence) if data['evo_max'].present? evo = data['evo_max'].to_i suggestions[:flb] = evo >= 4 suggestions[:ulb] = evo >= 5 suggestions[:transcendence] = evo >= 6 end # Max level based on rarity and uncap status suggestions[:max_level] = calculate_weapon_max_level(suggestions[:rarity], suggestions[:flb], suggestions[:ulb], suggestions[:transcendence]) # Series - look up by name to get the ID if data['series'].present? series = find_weapon_series_by_name(data['series']) suggestions[:series] = series.id if series end # Dates suggestions[:release_date] = parse_date(data['release_date']) if data['release_date'].present? suggestions[:flb_date] = parse_date(data['4star_date']) if data['4star_date'].present? suggestions[:ulb_date] = parse_date(data['5star_date']) if data['5star_date'].present? # External links - parse URLs to extract values suggestions[:gamewith] = parse_gamewith_url(data['link_gamewith']) if data['link_gamewith'].present? suggestions[:kamigame] = parse_kamigame_url(data['link_kamigame'], :weapon) if data['link_kamigame'].present? # Recruits (character recruited by this weapon) suggestions[:recruits] = data['recruit'] if data['recruit'].present? suggestions.compact end # Parse summon wiki text into suggestion fields def self.parse_summon(wiki_text) return {} if wiki_text.blank? data = parse_wiki_text(wiki_text) suggestions = {} suggestions[:name_en] = data['name'] if data['name'].present? suggestions[:name_jp] = data['jpname'] if data['jpname'].present? suggestions[:granblue_id] = data['id'] if data['id'].present? suggestions[:summon_id] = data['summonid'] if data['summonid'].present? # Rarity suggestions[:rarity] = Wiki.rarities[data['rarity']] if data['rarity'].present? # Element - capitalize first letter for case-insensitive lookup if data['element'].present? element_key = data['element'].strip.capitalize suggestions[:element] = Wiki.elements[element_key] end # Stats - summons use hp1/hp2/hp3/hp4/hp5 and atk1/atk2/atk3/atk4/atk5 suggestions[:min_hp] = data['hp1'].to_i if data['hp1'].present? suggestions[:max_hp] = data['hp2'].to_i if data['hp2'].present? suggestions[:max_hp_flb] = data['hp3'].to_i if data['hp3'].present? suggestions[:max_hp_ulb] = data['hp4'].to_i if data['hp4'].present? suggestions[:min_atk] = data['atk1'].to_i if data['atk1'].present? suggestions[:max_atk] = data['atk2'].to_i if data['atk2'].present? suggestions[:max_atk_flb] = data['atk3'].to_i if data['atk3'].present? suggestions[:max_atk_ulb] = data['atk4'].to_i if data['atk4'].present? # Uncap status - summons use max_evo (4=FLB, 5=ULB, 6=transcendence) if data['max_evo'].present? evo = data['max_evo'].to_i suggestions[:flb] = evo >= 4 suggestions[:ulb] = evo >= 5 suggestions[:transcendence] = evo >= 6 end # Max level based on rarity and uncap status suggestions[:max_level] = calculate_summon_max_level(suggestions[:rarity], suggestions[:flb], suggestions[:ulb], suggestions[:transcendence]) # Series (e.g., "Optimus", "Arcarum", etc.) suggestions[:series] = data['series'] if data['series'].present? # Sub-aura suggestions[:subaura] = Wiki.boolean.fetch(data['subaura'], false) if data['subaura'].present? # Dates suggestions[:release_date] = parse_date(data['release_date']) if data['release_date'].present? suggestions[:flb_date] = parse_date(data['4star_date']) if data['4star_date'].present? suggestions[:ulb_date] = parse_date(data['5star_date']) if data['5star_date'].present? # External links - parse URLs to extract values suggestions[:gamewith] = parse_gamewith_url(data['link_gamewith']) if data['link_gamewith'].present? suggestions[:kamigame] = parse_kamigame_url(data['link_kamigame'], :summon) if data['link_kamigame'].present? suggestions.compact end # Parse wiki text into a key-value hash def self.parse_wiki_text(wiki_text) lines = wiki_text.split("\n") data = {} stop_loop = false lines.each do |line| next if stop_loop # Stop parsing at gameplay notes section if line.include?('Gameplay Notes') stop_loop = true next end next unless line[0] == '|' && line.size > 2 key, value = line[1..].split('=', 2).map(&:strip) data[key] = value if value.present? end data end # Parse a date string into a Date object def self.parse_date(date_str) Date.parse(date_str) rescue ArgumentError, TypeError nil end # Parse Gamewith value to extract article ID # Handles multiple formats: # - Full URL: https://xn--bck3aza1a2if6kra4ee0hf.gamewith.jp/article/show/519325 # - Direct numeric ID: 519325 # - Template syntax: {{{link_gamewith|519325}}} # Output: "519325" def self.parse_gamewith_url(value) return nil if value.blank? value = value.to_s.strip # Handle MediaWiki template syntax {{{link_gamewith|VALUE}}} if value =~ /\{\{\{link_gamewith\|(\d+)\}\}\}/ return $1 end # If it's a full URL, extract the ID if value =~ /gamewith\.jp\/article\/show\/(\d+)/ $1 # If it's already just a numeric ID, return it elsif value =~ /^\d+$/ value end end # Parse Kamigame value to extract Japanese slug # Handles multiple formats: # - Full URL: https://kamigame.jp/グラブル/キャラクター/SSR水着リッチ.html # - Direct Japanese slug: SSR水着リッチ or SSR/アグニス # - Template syntax: {{{link_kamigame|VALUE}}} # entity_type: :character, :weapon, :summon def self.parse_kamigame_url(value, entity_type) return nil if value.blank? value = value.to_s.strip # Handle MediaWiki template syntax {{{link_kamigame|VALUE}}} # This may contain nested templates like {{{jpname|}}} if value =~ /\{\{\{link_kamigame\|([^}]*)\}\}\}/ extracted = $1 # If the extracted value contains nested templates, skip it return nil if extracted.include?('{{{') return nil if extracted.blank? value = extracted end # URL decode to handle percent-encoded Japanese characters decoded = URI.decode_www_form_component(value) # Check if it's a full URL if decoded.include?('kamigame.jp') case entity_type when :character # https://kamigame.jp/グラブル/キャラクター/SSR水着リッチ.html -> SSR水着リッチ if decoded =~ /キャラクター\/([^\/]+)\.html$/ $1 end when :weapon # https://kamigame.jp/グラブル/武器/SSR/ブラインド.html -> ブラインド if decoded =~ /武器\/(?:SSR|SR|R)\/([^\/]+)\.html$/ $1 end when :summon # https://kamigame.jp/グラブル/召喚石/SSR/アグニス.html -> SSR/アグニス if decoded =~ /召喚石\/((?:SSR|SR|R)\/[^\/]+)\.html$/ $1 end end else # Assume it's already just the slug value # Remove .html extension if present decoded.sub(/\.html$/, '') end rescue ArgumentError # Handle invalid URL encoding nil end # Seasonal series that indicate a character is only available during that season SEASONAL_SERIES = %w[holiday summer valentine halloween formal].freeze # Maps series values to CharacterSeason enum values SEASON_MAP = { 'valentine' => 1, 'formal' => 2, 'summer' => 3, 'halloween' => 4, 'holiday' => 5 }.freeze # Maps series to Promotion enum values for seasonal characters SEASONAL_PROMOTION_MAP = { 'holiday' => 9, 'summer' => 7, 'valentine' => 6, 'halloween' => 8, 'formal' => 11 }.freeze # Indicators in obtain field that mean character is gacha-available GACHA_INDICATORS = %w[premium flash legend classic classic2 grand zodiac valentine summer halloween holiday formal normal].freeze # Non-gacha obtain values NON_GACHA_INDICATORS = %w[rotb event side story promo eternal evoker archangel].freeze # Extract season from wiki series field # @param wiki_series [String] The series field value (e.g., "holiday", "summer") # @param obtain [String] The obtain field value # @return [Integer, nil] CharacterSeason enum value, or nil for non-seasonal characters def self.character_season_from_series(wiki_series, obtain) # Check series field first return SEASON_MAP[wiki_series] if SEASON_MAP.key?(wiki_series) # Check obtain field for seasonal indicators SEASON_MAP.each do |series, season_id| return season_id if obtain.include?(series) end # Non-seasonal characters have nil season nil end # Determine if character can be pulled from gacha # @param obtain [String] The obtain field value # @param wiki_series [String] The series field value # @return [Boolean] def self.gacha_available_from_obtain(obtain, wiki_series) return false if obtain.blank? # Check for non-gacha indicators return false if NON_GACHA_INDICATORS.any? { |indicator| obtain.include?(indicator) } # Check for gacha indicators GACHA_INDICATORS.any? { |indicator| obtain.include?(indicator) } end # Extract promotions array from obtain and series fields # @param obtain [String] The obtain field value # @param wiki_series [String] The series field value # @return [Array] Array of Promotion enum values def self.character_promotions_from_obtain(obtain, wiki_series) return [] unless gacha_available_from_obtain(obtain, wiki_series) promotions = [] # Seasonal characters ONLY get their seasonal promotion if SEASONAL_SERIES.include?(wiki_series) promotions << SEASONAL_PROMOTION_MAP[wiki_series] if SEASONAL_PROMOTION_MAP[wiki_series] return promotions end # Check if obtain indicates a seasonal banner (e.g., obtain=premium,holiday) SEASONAL_SERIES.each do |series| if obtain.include?(series) promotions << SEASONAL_PROMOTION_MAP[series] if SEASONAL_PROMOTION_MAP[series] return promotions end end # Standard characters get Premium, Flash, Legend by default promotions << 1 # Premium promotions << 4 # Flash promotions << 5 # Legend # Add Classic pools only if explicitly mentioned promotions << 2 if obtain.include?('classic') && !obtain.include?('classic2') promotions << 3 if obtain.include?('classic2') promotions.uniq.sort end # Calculate max level for weapons based on rarity and uncap status # R weapons: max 50 # SR weapons: max 75 # SSR weapons: max 100 (FLB: 150, ULB: 200, Trans: 250) def self.calculate_weapon_max_level(rarity, flb, ulb, transcendence) case rarity when 1 then 50 # R when 2 then 75 # SR else # SSR (3+) if transcendence 250 elsif ulb 200 elsif flb 150 else 100 end end end # Calculate max level for summons based on rarity and uncap status # R summons: max 30 # SR summons: max 60 # SSR summons: max 100 (FLB: 150, ULB: 200, Trans: 250) def self.calculate_summon_max_level(rarity, flb, ulb, transcendence) case rarity when 1 then 30 # R when 2 then 60 # SR else # SSR (3+) if transcendence 250 elsif ulb 200 elsif flb 150 else 100 end end end # Find a WeaponSeries by wiki name # Converts names like "Revenant Weapons" or "Revenant" to slug "revenant" def self.find_weapon_series_by_name(name) return nil if name.blank? # Convert wiki name to slug format slug = name .gsub(/\s*(Weapons?|Series)\s*$/i, '') .strip .downcase .gsub(/\s+/, '-') WeaponSeries.find_by(slug: slug) end end end end