Class: Retailer::PriceChecker

Inherits:
Object
  • Object
show all
Includes:
CatalogConstants
Defined in:
app/services/retailer/price_checker.rb

Overview

Service to check product prices and availability on retailer websites.
Uses Oxylabs Web Scraper API to fetch page content and extract data.

Examples:

Check a single catalog item

checker = Retailer::PriceChecker.new
result = checker.check(catalog_item)

Check all items for a catalog

checker = Retailer::PriceChecker.new
results = checker.check_catalog(catalog)

Constant Summary collapse

DEFAULT_GEO_LOCATIONS =

Default ZIP codes by country

{
  'USA' => '60047',  # Lake Zurich, IL (WarmlyYours HQ area)
  'CAN' => 'M5V3A8', # Toronto, ON
  'DEU' => '10115',  # Berlin
  'GBR' => 'SW1A1AA', # London
  'FRA' => '75001',  # Paris
  'ITA' => '00100',  # Rome
  'ESP' => '28001',  # Madrid
  'NLD' => '1011',   # Amsterdam
  'POL' => '00-001', # Warsaw
  'SWE' => '11120',  # Stockholm
  'BEL' => '1000'    # Brussels
}.freeze
CATALOG_RETAILER_TYPES =

Retailer type detection based on catalog ID
Uses CatalogConstants for maintainability

{
  # Home Depot
  HOME_DEPOT_USA => :home_depot_usa,
  HOME_DEPOT_CANADA => :home_depot_canada,
  # Costco
  COSTCO_CANADA => :costco_canada,
  # Wayfair
  WAYFAIR_USA => :wayfair,
  WAYFAIR_CANADA => :wayfair,
  WAYFAIR_GERMANY => :wayfair,
  # Rona/Lowe's
  RONA_CANADA => :rona,
  LOWES_USA => :lowes,
  LOWES_CANADA => :lowes,
  # Build.com (Ferguson Home)
  BUILD_COM => :build_com,
  # Amazon Seller Marketplaces
  AMAZON_SC_US_CATALOG_ID => :amazon,
  AMAZON_SC_CA_CATALOG_ID => :amazon,
  AMAZON_SC_FR_CATALOG_ID => :amazon,
  AMAZON_SC_IT_CATALOG_ID => :amazon,
  AMAZON_SC_ES_CATALOG_ID => :amazon,
  AMAZON_SC_DE_CATALOG_ID => :amazon,
  AMAZON_SC_NL_CATALOG_ID => :amazon,
  AMAZON_SC_PL_CATALOG_ID => :amazon,
  AMAZON_SC_UK_CATALOG_ID => :amazon,
  AMAZON_SC_SE_CATALOG_ID => :amazon,
  AMAZON_SC_BE_CATALOG_ID => :amazon,
  # Walmart Seller Marketplaces
  WALMART_SELLER_USA => :walmart_usa,
  WALMART_SELLER_CANADA => :walmart_canada,
  # Canadian Tire
  CANADIAN_TIRE => :canadian_tire,
  # Houzz
  HOUZZ => :houzz,
  # Best Buy Canada
  BESTBUY_CANADA => :bestbuy_canada
}.freeze

Constants included from CatalogConstants

CatalogConstants::ALL_MAIN_CATALOG_IDS, CatalogConstants::AMAZON_CATALOG_IDS, CatalogConstants::AMAZON_CA_CATALOG_IDS, CatalogConstants::AMAZON_EU_CATALOG_IDS, CatalogConstants::AMAZON_NA_SELLER_IDS, CatalogConstants::AMAZON_SC_BE_CATALOG_ID, CatalogConstants::AMAZON_SC_CATALOG_IDS, CatalogConstants::AMAZON_SC_CA_CATALOG_ID, CatalogConstants::AMAZON_SC_DE_CATALOG_ID, CatalogConstants::AMAZON_SC_ES_CATALOG_ID, CatalogConstants::AMAZON_SC_FR_CATALOG_ID, CatalogConstants::AMAZON_SC_IT_CATALOG_ID, CatalogConstants::AMAZON_SC_NL_CATALOG_ID, CatalogConstants::AMAZON_SC_PL_CATALOG_ID, CatalogConstants::AMAZON_SC_SE_CATALOG_ID, CatalogConstants::AMAZON_SC_UK_CATALOG_ID, CatalogConstants::AMAZON_SC_US_CATALOG_ID, CatalogConstants::AMAZON_SELLER_IDS, CatalogConstants::AMAZON_US_CATALOG_IDS, CatalogConstants::AMAZON_VC_CATALOG_IDS, CatalogConstants::AMAZON_VC_CA_CATALOG_ID, CatalogConstants::AMAZON_VC_CA_CATALOG_IDS, CatalogConstants::AMAZON_VC_DIRECT_FULFILLMENT_CATALOG_IDS, CatalogConstants::AMAZON_VC_US_CATALOG_IDS, CatalogConstants::AMAZON_VC_US_WASN4_CATALOG_ID, CatalogConstants::AMAZON_VC_US_WAX7V_CATALOG_ID, CatalogConstants::AMAZON_VC_WAT0F_CA_CATALOG_ID, CatalogConstants::AMAZON_VC_WAT4D_CA_CATALOG_ID, CatalogConstants::AMAZON_VENDOR_CODE_TO_CATALOG_ID, CatalogConstants::BESTBUY_CANADA, CatalogConstants::BUILD_COM, CatalogConstants::CANADIAN_TIRE, CatalogConstants::CA_CATALOG_ID, CatalogConstants::COSTCO_CANADA, CatalogConstants::COSTCO_CATALOGS, CatalogConstants::COSTCO_USA, CatalogConstants::EU_CATALOG_ID, CatalogConstants::HOME_DEPOT_CANADA, CatalogConstants::HOME_DEPOT_CATALOGS, CatalogConstants::HOME_DEPOT_USA, CatalogConstants::HOUZZ, CatalogConstants::LOCALE_TO_CATALOG, CatalogConstants::LOWES_CANADA, CatalogConstants::LOWES_USA, CatalogConstants::MARKETPLACE_CATALOGS, CatalogConstants::PRICE_CHECK_ENABLED_CATALOGS, CatalogConstants::RONA_CANADA, CatalogConstants::US_CATALOG_ID, CatalogConstants::VENDOR_CATALOGS, CatalogConstants::WALMART_CATALOGS, CatalogConstants::WALMART_SELLER_CANADA, CatalogConstants::WALMART_SELLER_USA, CatalogConstants::WAYFAIR_CANADA, CatalogConstants::WAYFAIR_CATALOGS, CatalogConstants::WAYFAIR_GERMANY, CatalogConstants::WAYFAIR_USA

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from CatalogConstants

amazon_catalog?, amazon_seller_catalog?, costco_catalog?, home_depot_catalog?, marketplace_catalog?, price_check_enabled?, vendor_catalog?, walmart_catalog?, wayfair_catalog?

Constructor Details

#initialize(options = {}) ⇒ PriceChecker

Returns a new instance of PriceChecker.



75
76
77
78
79
# File 'app/services/retailer/price_checker.rb', line 75

def initialize(options = {})
  @api = options[:api] || Retailer::OxylabsApi.new(timeout: 120)
  @url_constructor = options[:url_constructor] || Retailer::UrlConstructor.new
  @logger = options[:logger] || Rails.logger
end

Instance Attribute Details

#apiObject (readonly)

Returns the value of attribute api.



73
74
75
# File 'app/services/retailer/price_checker.rb', line 73

def api
  @api
end

#loggerObject (readonly)

Returns the value of attribute logger.



73
74
75
# File 'app/services/retailer/price_checker.rb', line 73

def logger
  @logger
end

#url_constructorObject (readonly)

Returns the value of attribute url_constructor.



73
74
75
# File 'app/services/retailer/price_checker.rb', line 73

def url_constructor
  @url_constructor
end

Instance Method Details

#check(catalog_item, geo_location: nil) ⇒ CatalogItemRetailerProbe

Check a single catalog item and record the result

Parameters:

  • catalog_item (CatalogItem)
  • geo_location (String) (defaults to: nil)

    Optional ZIP code override

Returns:



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'app/services/retailer/price_checker.rb', line 85

def check(catalog_item, geo_location: nil)
  return nil if catalog_item.skip_url_checks?

  catalog = catalog_item.catalog
  retailer_type = detect_retailer_type(catalog)

  # Get URL - prefer hardcoded, fall back to generated
  url = url_constructor.product_url(catalog_item)
  return nil if url.blank? && retailer_type != :amazon

  geo_location ||= default_geo_location(catalog_item)
  start_time = Time.current

  # Create the check record with the URL that will be used
  check = catalog_item.retailer_probes.build(
    status: 'pending',
    geo_location: geo_location,
    url: url
  )

  begin
    result = fetch_product(catalog_item, catalog, retailer_type, geo_location)
    process_result(check, result, start_time, retailer_type, catalog_item)
  rescue StandardError => e
    check.status = 'failed'
    check.error_message = "#{e.class}: #{e.message}"
    @logger.error "[PriceChecker] Error checking #{catalog_item.id}: #{e.message}"
  end

  check.save!

  # Update the catalog item's url_valid, url_last_checked, and retail_price fields
  update_attrs = {
    url_valid: check.page_accessible?,
    url_last_checked: Time.current
  }

  # Only store retail_price for vendor (1P) catalogs where the retailer
  # sets the price. For marketplace (3P) catalogs the price is ours, so
  # writing it back as "advertised price" is circular and confusing.
  if check.status == 'success' && check.price.present? && catalog.retailer_type_vendor?
    update_attrs[:retail_price] = check.price
    update_attrs[:retailer_price_updated_at] = Time.current
  end

  # Update URL if we discovered a better one (e.g., from search results)
  if @discovered_url.present? && is_direct_product_url?(@discovered_url)
    clean_url = strip_tracking_params(@discovered_url)
    if catalog_item.url.blank? || catalog_item.url.include?('search')
      update_attrs[:url] = clean_url
      @logger.info "[PriceChecker] Discovered URL for item #{catalog_item.id}: #{clean_url}"
    end
  end

  catalog_item.update_columns(update_attrs)

  check
end

#check_all_retailersHash

Check all active items across all retailer catalogs

Returns:

  • (Hash)

    Summary of results



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'app/services/retailer/price_checker.rb', line 160

def check_all_retailers
  results = { total: 0, success: 0, failed: 0, catalogs: {} }

  retailer_catalog_items.find_each do |item|
    check_record = check(item)
    next unless check_record

    results[:total] += 1
    results[:success] += 1 if check_record.status == 'success'
    results[:failed] += 1 if check_record.status.in?(%w[failed not_found])

    catalog_name = item.catalog.name
    results[:catalogs][catalog_name] ||= { success: 0, failed: 0 }
    results[:catalogs][catalog_name][:success] += 1 if check_record.status == 'success'
    results[:catalogs][catalog_name][:failed] += 1 if check_record.status.in?(%w[failed not_found])
  end

  results
end

#check_catalog(catalog) ⇒ Array<CatalogItemRetailerProbe>

Check all active items in a catalog

Parameters:

Returns:



147
148
149
150
151
152
153
154
155
156
# File 'app/services/retailer/price_checker.rb', line 147

def check_catalog(catalog)
  items = catalog.catalog_items
                 .where(state: 'active')
                 .where(skip_url_checks: false)

  # For non-Amazon catalogs, require a URL
  items = items.where.not(url: [nil, '']) unless catalog.name.match?(/amazon/i)

  items.map { |item| check(item) }.compact
end