|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require 'net/http' |
| 4 | +require 'json' |
| 5 | + |
| 6 | +module Pwb |
| 7 | + module Hpg |
| 8 | + # Imports games and listings from the legacy HPG backend into PWB models. |
| 9 | + # |
| 10 | + # The legacy backend at hpg-scoot.homestocompare.com exposes: |
| 11 | + # GET /api_public/v4/scoots/show_games/:scoot_slug — all games + scoot metadata |
| 12 | + # GET /api_public/v4/realty_game_summary/:game_slug — single game with listings |
| 13 | + # GET /api_public/v4/game_sale_listings/show_rgl/:uuid — individual listing with photos |
| 14 | + # |
| 15 | + # Usage: |
| 16 | + # importer = Pwb::Hpg::LegacyImporter.new(website) |
| 17 | + # importer.import_all_games("hpg-scoot") |
| 18 | + # importer.import_game("hpg-scoot", "steel-city-edition-sheffield-house-prices") |
| 19 | + # |
| 20 | + class LegacyImporter |
| 21 | + BASE_URL = ENV.fetch('HPG_LEGACY_API_BASE', 'https://hpg-scoot.homestocompare.com') |
| 22 | + |
| 23 | + attr_reader :website, :stats |
| 24 | + |
| 25 | + def initialize(website) |
| 26 | + @website = website |
| 27 | + @stats = { games: 0, assets: 0, listings: 0, photos: 0, errors: [] } |
| 28 | + end |
| 29 | + |
| 30 | + # Import all games from the legacy scoot endpoint |
| 31 | + def import_all_games(scoot_slug) |
| 32 | + puts "Fetching games list from #{BASE_URL}..." |
| 33 | + data = fetch_json("/api_public/v4/scoots/show_games/#{scoot_slug}") |
| 34 | + |
| 35 | + scoot = data['scoot'] |
| 36 | + available_games = scoot&.dig('available_games_details') || [] |
| 37 | + puts "Found #{available_games.size} games to import." |
| 38 | + |
| 39 | + available_games.each do |game_summary| |
| 40 | + slug = game_summary['realty_game_slug'] |
| 41 | + begin |
| 42 | + import_single_game(slug, game_summary) |
| 43 | + rescue StandardError => e |
| 44 | + msg = "Error importing game '#{slug}': #{e.message}" |
| 45 | + puts " ERROR: #{msg}" |
| 46 | + @stats[:errors] << msg |
| 47 | + end |
| 48 | + end |
| 49 | + |
| 50 | + print_stats |
| 51 | + end |
| 52 | + |
| 53 | + # Import a single game by slug |
| 54 | + def import_game(slug) |
| 55 | + import_single_game(slug) |
| 56 | + print_stats |
| 57 | + end |
| 58 | + |
| 59 | + private |
| 60 | + |
| 61 | + def import_single_game(slug, available_game_details = nil) |
| 62 | + puts "\nImporting game: #{slug}..." |
| 63 | + |
| 64 | + summary = fetch_json("/api_public/v4/realty_game_summary/#{slug}") |
| 65 | + game_details = summary['realty_game_details'] |
| 66 | + price_inputs = summary['price_guess_inputs'] |
| 67 | + game_listings_data = price_inputs&.dig('game_listings') || [] |
| 68 | + |
| 69 | + game = find_or_create_game(slug, game_details, price_inputs, available_game_details) |
| 70 | + puts " Game: #{game.title} (#{game.slug})" |
| 71 | + |
| 72 | + game_listings_data.each_with_index do |gl_data, idx| |
| 73 | + begin |
| 74 | + import_listing(game, gl_data, idx) |
| 75 | + rescue StandardError => e |
| 76 | + msg = "Error importing listing #{idx} for game '#{slug}': #{e.message}" |
| 77 | + puts " ERROR: #{msg}" |
| 78 | + @stats[:errors] << msg |
| 79 | + end |
| 80 | + end |
| 81 | + |
| 82 | + @stats[:games] += 1 |
| 83 | + end |
| 84 | + |
| 85 | + def find_or_create_game(request_slug, game_details, price_inputs, available_game_details) |
| 86 | + # game_global_slug is sometimes empty; fall back to game_default_locale or the slug we fetched with |
| 87 | + slug = game_details['game_global_slug'].presence || |
| 88 | + game_details['game_default_locale'].presence || |
| 89 | + request_slug |
| 90 | + validation_rules = price_inputs&.dig('guessed_price_validation') || {} |
| 91 | + |
| 92 | + # Merge session/estimate counts from available_games_details if present |
| 93 | + sessions_count = available_game_details&.dig('game_sessions_count') || 0 |
| 94 | + estimates_count = available_game_details&.dig('guessed_prices_count') || 0 |
| 95 | + hidden = available_game_details&.dig('is_hidden_from_landing_page') || false |
| 96 | + default_country = available_game_details&.dig('game_default_country') |
| 97 | + |
| 98 | + game = website.realty_games.find_or_initialize_by(slug: slug) |
| 99 | + game.assign_attributes( |
| 100 | + title: game_details['game_title'], |
| 101 | + description: game_details['game_description'], |
| 102 | + bg_image_url: game_details['game_bg_image_url'], |
| 103 | + default_currency: game_details['default_game_currency'] || 'EUR', |
| 104 | + default_country: default_country, |
| 105 | + start_at: parse_time(game_details['game_start_at']), |
| 106 | + end_at: parse_time(game_details['game_end_at']), |
| 107 | + validation_rules: validation_rules, |
| 108 | + sessions_count: sessions_count, |
| 109 | + estimates_count: estimates_count, |
| 110 | + hidden_from_landing_page: hidden, |
| 111 | + active: true |
| 112 | + ) |
| 113 | + game.save! |
| 114 | + game |
| 115 | + end |
| 116 | + |
| 117 | + def import_listing(game, gl_data, index) |
| 118 | + legacy_uuid = gl_data['uuid'] |
| 119 | + listing_details = gl_data['listing_details'] || {} |
| 120 | + |
| 121 | + # Create or find the RealtyAsset (physical property) |
| 122 | + asset = find_or_create_asset(gl_data, listing_details, legacy_uuid) |
| 123 | + |
| 124 | + # Create or find the SaleListing (price data) |
| 125 | + find_or_create_sale_listing(asset, gl_data) |
| 126 | + |
| 127 | + # Create the photo from gl_image_url |
| 128 | + create_photo_from_gl(asset, gl_data) |
| 129 | + |
| 130 | + # Fetch individual listing for additional photos |
| 131 | + fetch_and_create_photos(asset, legacy_uuid) |
| 132 | + |
| 133 | + # Create the GameListing (join record) |
| 134 | + find_or_create_game_listing(game, asset, gl_data, legacy_uuid, index) |
| 135 | + |
| 136 | + puts " Listing #{index + 1}: #{gl_data['gl_title'] || listing_details['listing_title'] || 'untitled'}" |
| 137 | + end |
| 138 | + |
| 139 | + def find_or_create_asset(gl_data, listing_details, legacy_uuid) |
| 140 | + # Use legacy UUID stored in extra_data to find existing assets |
| 141 | + existing_gl = Pwb::GameListing.where("extra_data->>'legacy_listing_uuid' = ?", legacy_uuid).first |
| 142 | + if existing_gl |
| 143 | + @stats[:assets] += 0 # not new |
| 144 | + return existing_gl.realty_asset |
| 145 | + end |
| 146 | + |
| 147 | + # Build asset attributes from the legacy data |
| 148 | + title = listing_details['listing_title'] || gl_data['gl_title'] |
| 149 | + street_address = listing_details['listing_street_address'] || '' |
| 150 | + city = listing_details['listing_city'] || extract_city_from_sale_listing(gl_data) |
| 151 | + country = gl_data['gl_country_code'].presence || listing_details['country_code'] |
| 152 | + postal_code = listing_details['listing_postal_code'] |
| 153 | + latitude = parse_float(gl_data['gl_latitude']) |
| 154 | + longitude = parse_float(gl_data['gl_longitude']) |
| 155 | + bedrooms = listing_details['listing_count_bedrooms'] || 0 |
| 156 | + bathrooms = listing_details['listing_count_bathrooms'] || 0 |
| 157 | + garages = listing_details['listing_count_garages'] || 0 |
| 158 | + |
| 159 | + asset = Pwb::RealtyAsset.new( |
| 160 | + website: website, |
| 161 | + street_address: street_address, |
| 162 | + city: city, |
| 163 | + country: country, |
| 164 | + postal_code: postal_code, |
| 165 | + latitude: latitude, |
| 166 | + longitude: longitude, |
| 167 | + count_bedrooms: bedrooms.to_i, |
| 168 | + count_bathrooms: bathrooms.to_f, |
| 169 | + count_garages: garages.to_i, |
| 170 | + reference: "HPG-#{legacy_uuid[0..7]}" |
| 171 | + ) |
| 172 | + # Write title to the DB column directly (RealtyAsset overrides title method to return nil) |
| 173 | + asset.write_attribute(:title, title) |
| 174 | + asset.save! |
| 175 | + @stats[:assets] += 1 |
| 176 | + asset |
| 177 | + end |
| 178 | + |
| 179 | + def find_or_create_sale_listing(asset, gl_data) |
| 180 | + price_cents = gl_data['price_to_be_guessed_cents'] || 0 |
| 181 | + currency = gl_data['source_listing_currency'] || 'EUR' |
| 182 | + |
| 183 | + # Only create if no active sale listing exists for this asset |
| 184 | + existing = asset.sale_listings.active_listing.first |
| 185 | + return existing if existing |
| 186 | + |
| 187 | + listing = asset.sale_listings.create!( |
| 188 | + price_sale_current_cents: price_cents, |
| 189 | + price_sale_current_currency: currency, |
| 190 | + visible: true, |
| 191 | + active: true |
| 192 | + ) |
| 193 | + @stats[:listings] += 1 |
| 194 | + listing |
| 195 | + end |
| 196 | + |
| 197 | + def create_photo_from_gl(asset, gl_data) |
| 198 | + image_url = gl_data['gl_image_url'] |
| 199 | + return unless image_url.present? |
| 200 | + |
| 201 | + # Skip if photo with this URL already exists |
| 202 | + return if asset.prop_photos.exists?(external_url: image_url) |
| 203 | + |
| 204 | + asset.prop_photos.create!( |
| 205 | + external_url: image_url, |
| 206 | + sort_order: 0 |
| 207 | + ) |
| 208 | + @stats[:photos] += 1 |
| 209 | + end |
| 210 | + |
| 211 | + def fetch_and_create_photos(asset, legacy_uuid) |
| 212 | + data = fetch_json("/api_public/v4/game_sale_listings/show_rgl/#{legacy_uuid}") |
| 213 | + rgl = data['realty_game_listing'] || {} |
| 214 | + pics = rgl['game_listing_pics'] || [] |
| 215 | + |
| 216 | + # Also check sale_listing pics |
| 217 | + sale_listing = data['sale_listing'] || {} |
| 218 | + sale_pics = sale_listing['sale_listing_pics'] || [] |
| 219 | + |
| 220 | + all_pics = (pics + sale_pics).uniq { |p| p['uuid'] } |
| 221 | + |
| 222 | + all_pics.each_with_index do |pic, idx| |
| 223 | + url = pic.dig('image_details', 'url') || pic['photo_slug'] |
| 224 | + next unless url.present? |
| 225 | + next if asset.prop_photos.exists?(external_url: url) |
| 226 | + |
| 227 | + asset.prop_photos.create!( |
| 228 | + external_url: url, |
| 229 | + description: pic['photo_description'], |
| 230 | + sort_order: pic['sort_order'] || (idx + 1) |
| 231 | + ) |
| 232 | + @stats[:photos] += 1 |
| 233 | + end |
| 234 | + |
| 235 | + # Also update asset with fuller sale_listing data if available |
| 236 | + update_asset_from_sale_listing(asset, sale_listing) |
| 237 | + rescue StandardError => e |
| 238 | + # Non-critical: photo fetch failure shouldn't stop the import |
| 239 | + puts " Warning: Could not fetch photos for #{legacy_uuid}: #{e.message}" |
| 240 | + end |
| 241 | + |
| 242 | + def update_asset_from_sale_listing(asset, sale_listing_data) |
| 243 | + return if sale_listing_data.empty? |
| 244 | + |
| 245 | + updates = {} |
| 246 | + updates[:city] = sale_listing_data['city'] if sale_listing_data['city'].present? && asset.city.blank? |
| 247 | + updates[:street_address] = sale_listing_data['street_address'] if sale_listing_data['street_address'].present? && asset.street_address.blank? |
| 248 | + updates[:postal_code] = sale_listing_data['postal_code'] if sale_listing_data['postal_code'].present? && asset.postal_code.blank? |
| 249 | + updates[:region] = sale_listing_data['region'] if sale_listing_data['region'].present? && asset.region.blank? |
| 250 | + updates[:country] = sale_listing_data['country'] if sale_listing_data['country'].present? && asset.country.blank? |
| 251 | + updates[:latitude] = sale_listing_data['latitude'].to_f if sale_listing_data['latitude'].present? && asset.latitude.blank? |
| 252 | + updates[:longitude] = sale_listing_data['longitude'].to_f if sale_listing_data['longitude'].present? && asset.longitude.blank? |
| 253 | + updates[:count_bedrooms] = sale_listing_data['count_bedrooms'].to_i if sale_listing_data['count_bedrooms'].present? && asset.count_bedrooms == 0 |
| 254 | + updates[:count_bathrooms] = sale_listing_data['count_bathrooms'].to_f if sale_listing_data['count_bathrooms'].present? && asset.count_bathrooms == 0.0 |
| 255 | + |
| 256 | + asset.update!(updates) if updates.any? |
| 257 | + end |
| 258 | + |
| 259 | + def find_or_create_game_listing(game, asset, gl_data, legacy_uuid, index) |
| 260 | + existing = game.game_listings.find_by(realty_asset: asset) |
| 261 | + return existing if existing |
| 262 | + |
| 263 | + extra_data = { |
| 264 | + 'legacy_listing_uuid' => legacy_uuid, |
| 265 | + 'legacy_game_listing_id' => gl_data['id'], |
| 266 | + 'gl_origin_url' => gl_data['gl_origin_url'], |
| 267 | + 'gl_vicinity' => gl_data['gl_vicinity'], |
| 268 | + 'gl_country_code' => gl_data['gl_country_code'], |
| 269 | + 'source_listing_currency' => gl_data['source_listing_currency'], |
| 270 | + 'guessed_prices_count' => gl_data['guessed_prices_count'], |
| 271 | + 'is_sale_listing_price_poll' => gl_data['is_sale_listing_price_poll'], |
| 272 | + 'price_to_be_guessed_cents' => gl_data['price_to_be_guessed_cents'] |
| 273 | + } |
| 274 | + |
| 275 | + display_title = gl_data['gl_title_atr'].presence || gl_data['gl_title'] |
| 276 | + sort_order = gl_data['listing_position_in_game'] || gl_data['position_in_game'] || index |
| 277 | + |
| 278 | + game.game_listings.create!( |
| 279 | + realty_asset: asset, |
| 280 | + sort_order: sort_order.to_i, |
| 281 | + visible: gl_data['visible_in_game'] != false, |
| 282 | + display_title: display_title, |
| 283 | + extra_data: extra_data |
| 284 | + ) |
| 285 | + end |
| 286 | + |
| 287 | + # HTTP helpers |
| 288 | + |
| 289 | + def fetch_json(path) |
| 290 | + uri = URI("#{BASE_URL}#{path}") |
| 291 | + http = Net::HTTP.new(uri.host, uri.port) |
| 292 | + if uri.scheme == 'https' |
| 293 | + http.use_ssl = true |
| 294 | + http.verify_mode = OpenSSL::SSL::VERIFY_PEER |
| 295 | + # Disable CRL checking which some legacy servers don't support |
| 296 | + http.verify_callback = ->(_preverify_ok, store_ctx) { |
| 297 | + # Accept the cert if the only error is CRL-related |
| 298 | + err = store_ctx.error |
| 299 | + err == 0 || err == OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL |
| 300 | + } |
| 301 | + end |
| 302 | + |
| 303 | + request = Net::HTTP::Get.new(uri) |
| 304 | + response = http.request(request) |
| 305 | + |
| 306 | + unless response.is_a?(Net::HTTPSuccess) |
| 307 | + raise "HTTP #{response.code} for #{path}: #{response.body&.truncate(200)}" |
| 308 | + end |
| 309 | + |
| 310 | + JSON.parse(response.body) |
| 311 | + end |
| 312 | + |
| 313 | + # Utility helpers |
| 314 | + |
| 315 | + def parse_time(value) |
| 316 | + return nil if value.blank? |
| 317 | + Time.parse(value) |
| 318 | + rescue ArgumentError |
| 319 | + nil |
| 320 | + end |
| 321 | + |
| 322 | + def parse_float(value) |
| 323 | + return nil if value.blank? |
| 324 | + float = value.to_f |
| 325 | + float == 0.0 ? nil : float |
| 326 | + end |
| 327 | + |
| 328 | + def extract_city_from_sale_listing(gl_data) |
| 329 | + # The listing_details sometimes has city info, fall back to vicinity |
| 330 | + gl_data['gl_vicinity'].presence |
| 331 | + end |
| 332 | + |
| 333 | + def print_stats |
| 334 | + puts "\n=== Import Summary ===" |
| 335 | + puts " Games imported: #{@stats[:games]}" |
| 336 | + puts " Assets created: #{@stats[:assets]}" |
| 337 | + puts " Sale listings created: #{@stats[:listings]}" |
| 338 | + puts " Photos created: #{@stats[:photos]}" |
| 339 | + if @stats[:errors].any? |
| 340 | + puts " Errors (#{@stats[:errors].size}):" |
| 341 | + @stats[:errors].each { |e| puts " - #{e}" } |
| 342 | + else |
| 343 | + puts " Errors: none" |
| 344 | + end |
| 345 | + puts "======================" |
| 346 | + end |
| 347 | + end |
| 348 | + end |
| 349 | +end |
0 commit comments