Skip to content

Commit e523bb0

Browse files
etewiahclaude
andcommitted
Add HPG legacy data migration service and rake tasks
Imports games, listings, assets, and photos from the legacy hpg-scoot.homestocompare.com API into PWB models. Idempotent and error-resilient with 25 specs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 666dd29 commit e523bb0

File tree

3 files changed

+961
-0
lines changed

3 files changed

+961
-0
lines changed
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
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

lib/tasks/hpg_migrate.rake

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
namespace :hpg do
4+
desc 'Migrate all games from legacy HPG backend. Usage: rails hpg:migrate_legacy[subdomain]'
5+
task :migrate_legacy, [:subdomain] => :environment do |_t, args|
6+
subdomain = args[:subdomain]
7+
abort 'Usage: rails hpg:migrate_legacy[subdomain]' if subdomain.blank?
8+
9+
website = Pwb::Website.find_by(subdomain: subdomain)
10+
abort "Website not found for subdomain: #{subdomain}" unless website
11+
12+
scoot_slug = ENV.fetch('HPG_SCOOT_SLUG', subdomain)
13+
puts "Migrating legacy HPG games for #{subdomain} (scoot: #{scoot_slug})..."
14+
15+
importer = Pwb::Hpg::LegacyImporter.new(website)
16+
importer.import_all_games(scoot_slug)
17+
end
18+
19+
desc 'Migrate a single game from legacy HPG backend. Usage: rails hpg:migrate_game[subdomain,game-slug]'
20+
task :migrate_game, [:subdomain, :slug] => :environment do |_t, args|
21+
subdomain = args[:subdomain]
22+
slug = args[:slug]
23+
abort 'Usage: rails hpg:migrate_game[subdomain,game-slug]' if subdomain.blank? || slug.blank?
24+
25+
website = Pwb::Website.find_by(subdomain: subdomain)
26+
abort "Website not found for subdomain: #{subdomain}" unless website
27+
28+
puts "Migrating legacy HPG game '#{slug}' for #{subdomain}..."
29+
30+
importer = Pwb::Hpg::LegacyImporter.new(website)
31+
importer.import_game(slug)
32+
end
33+
end

0 commit comments

Comments
 (0)