Skip to content

Retailer Price Monitoring System

The Retailer Price Monitoring system automatically tracks product prices and availability across third-party retail partners (Home Depot, Costco, Wayfair, Amazon, etc.). It detects MAP (Minimum Advertised Price) violations for vendor/1P catalogs and provides visibility into retailer pricing behavior.

Path: Catalog Items → [Select Item] → Retailer Probes tab
URL: https://crm.warmlyyours.me:3000/en-US/catalog_items/{id}?tab=retailer_probes

Only visible for catalog items in catalogs with external_price_check_enabled: true.

Path: Catalogs → [Select Catalog] → “MAP Violations” link
Direct Search: ProductCatalogSearch with map_violation: true filter

Daily automated checks via RetailerProbeWorker:

FeatureDescription
Price ExtractionCaptures current/sale price and original/was price
Availability DetectionDetects “out of stock” and “unavailable” indicators
URL ValidationTracks if product pages are accessible
History TrackingStores all checks in catalog_item_retailer_probes table

Users with :update capability on a catalog item can trigger an immediate price check:

  • Button located on the Retailer Probes tab
  • Uses Oxylabs Realtime API for immediate results
  • Updates retail_price column on success

For vendor/1P catalogs (Home Depot, Costco, Wayfair, Lowe’s, etc.):

FieldDescription
retailer_type:marketplace (3P) or :vendor (1P)
map_percentageDefault 80% of MSRP (20% max discount)
map_priceCalculated as msrp * map_percentage
map_violationtrue if retail_price < map_price

For vendor catalogs, displays:

  • Retailer Type Badge: “Vendor (1P)” or “Marketplace (3P)”
  • MAP Percentage: e.g., “80% of MSRP”
  • MAP Violations Link: Quick search for active items in violation
RetailerExtractor ClassPrice Extraction
Amazon (all markets)Retailer::Extractors::AmazonParsed JSON from amazon_product
Home Depot US/CARetailer::Extractors::HomeDepotJSON-LD + HTML selectors
Costco CARetailer::Extractors::CostcoJSON-LD + aria-labels
Wayfair US/CA/DERetailer::Extractors::Wayfairdata-test-id attributes
Walmart US/CARetailer::Extractors::Walmart__NEXT_DATA__ + JSON-LD
Lowe’s US/CARetailer::Extractors::LowesJSON-LD + CSS selectors
Rona/RenoDepotRetailer::Extractors::RonaBrowser automation + parsing
Build.com (Ferguson)Retailer::Extractors::BuildComJSON-LD + URL discovery
Canadian TireRetailer::Extractors::CanadianTireJSON-LD + CSS selectors
HouzzRetailer::Extractors::HouzzJSON-LD extraction
Best Buy CanadaRetailer::Extractors::BestbuyCanadaJSON-LD extraction

The system follows a clean architecture with clear separation:

┌─────────────────────────────────────────────────────────────┐
│ PriceChecker │
│ (Orchestration - Fetch + Delegate) │
│ • check(catalog_item) • check_catalog(catalog) │
│ • fetch_product(...) • process_result(...) │
└─────────────────────────────────────────────────────────────┘
│ Delegates to
┌─────────────────────────────────────────────────────────────┐
│ Extractors::Factory.for(catalog) │
│ (Returns correct extractor) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Extractors (Per Retailer) │
│ (Payload Building + Data Extraction) │
│ │
│ Class Methods: Instance Methods: │
│ • build_payload(url:) • extract(check, content) │
│ • search_payload(query:) • validate_product_identity │
│ • discovery_payload • discovered_url │
└─────────────────────────────────────────────────────────────┘
│ Uses
┌─────────────────────────────────────────────────────────────┐
│ OxylabsApi │
│ (Pure HTTP Client - Transport Only) │
│ • request(payload) • submit_job(payload) │
│ • job_status(id) • job_results(id) │
│ • poll_for_results(id) • submit_jobs_batch(payloads) │
└─────────────────────────────────────────────────────────────┘

All retailers use the same code path through the factory:

# In PriceChecker#process_result - unified for ALL retailers
extractor = Retailer::Extractors::Factory.for(catalog_item.catalog)
extractor.extract(check, content)
# Validate product identity (prevents false positives from redirects)
unless extractor.validate_product_identity(check, content, catalog_item)
check.price = nil # Clear extracted data on mismatch
end

Each extractor handles its own extraction logic:

# Get the right extractor for a catalog
extractor = Retailer::Extractors::Factory.for(catalog)
# Build payload (class method - knows retailer API specifics)
payload = extractor.class.build_payload(url: product_url)
# Make request (API client - knows HTTP)
api = Retailer::OxylabsApi.new
result = api.request(payload)
# Extract data (instance method - knows HTML structure)
extractor.extract(check, Retailer::OxylabsApi.html_content(result))

Two integration methods depending on use case:

MethodUse CaseEndpoint
RealtimeSingle “Probe Now” requestsSynchronous, blocks until result
Push-PullDaily batch processingAsync with webhook callback
POST https://api.warmlyyours.com/v1/oxylabs/results?token=<jwt>

Authentication via time-limited JWT token (24 hours):

  • Generated by Retailer::CallbackTokenService
  • Embedded in callback URL when submitting batch jobs
  • Validated by Api::V1::Oxylabs::WebhooksController
EnvironmentBatch MethodReason
DevelopmentPollingLocal endpoints not reachable from Oxylabs
ProductionWebhook callbackMore efficient, no polling overhead
ColumnTypeDescription
catalog_item_idbigintForeign key to catalog_items
statusstringsuccess, failed, not_found, pending
urlstringURL that was checked
pricedecimalCurrent/sale price extracted
regular_pricedecimalOriginal/was price (if on sale)
currencystringCurrency code (USD, CAD)
product_availablebooleanAvailability status
page_accessiblebooleanWhether page loaded successfully
error_messagetextError details if failed
raw_titlestringProduct title from page
scraper_sourcestringamazon, home_depot, wayfair, etc.
geo_locationstringZIP code used for check
created_attimestampWhen check was performed
ColumnTypeDefaultDescription
retailer_typeinteger00=marketplace, 1=vendor
map_percentagedecimal0.80MAP as percentage of MSRP
external_price_check_enabledbooleanfalseEnable retailer probes
ColumnDescription
retail_priceStores last successfully pulled retailer price
urlStored product URL (discovered or manual)
ServicePurpose
Retailer::OxylabsApiPure HTTP client for Oxylabs API
Retailer::PriceCheckerSingle-item price checks (realtime)
Retailer::BatchPriceCheckerBatch processing (push-pull)
Retailer::UrlConstructorBuilds product URLs per retailer
Retailer::CallbackTokenServiceJWT token generation/validation
Retailer::WebhookResultProcessorProcesses webhook payloads
ExtractorKey Features
Retailer::Extractors::BaseShared Nokogiri parsing, JSON-LD extraction, validate_product_identity
Retailer::Extractors::FactoryReturns correct extractor for catalog ID
Retailer::Extractors::AmazonHandles JSON from amazon_product, ASIN validation override
Retailer::Extractors::HomeDepotdata-automation + JSON-LD
Retailer::Extractors::Costcoaria-labels + JSON-LD
Retailer::Extractors::Wayfairdata-test-id selectors, URL discovery
Retailer::Extractors::Walmart__NEXT_DATA__ parsing + JSON-LD fallback
Retailer::Extractors::LowesJSON-LD + CSS selectors
Retailer::Extractors::RonaBrowser automation + URL discovery
Retailer::Extractors::BuildComJSON-LD extraction, search page URL discovery
Retailer::Extractors::CanadianTireJSON-LD + CSS selectors
Retailer::Extractors::HouzzJSON-LD extraction
Retailer::Extractors::BestbuyCanadaJSON-LD extraction
Retailer::Extractors::GenericFallback for unknown retailers

All extractors inherit validate_product_identity from Base class, which prevents false positives when retailers redirect to different products:

# In Retailer::Extractors::Base
def validate_product_identity(check, content, catalog_item)
identifiers = collect_product_identifiers(catalog_item)
# Checks SKU, UPC, third_party_part_number, third_party_sku, parent_sku
# Returns false if none found in page content or URL
end

Amazon overrides this for direct ASIN comparison:

# In Retailer::Extractors::Amazon
def validate_product_identity(check, content, catalog_item)
if content.is_a?(Hash) && content['asin'].present?
return content['asin'] == catalog_item.amazon_asin
end
super # Fall back to base class for HTML content
end
WorkerSchedulePurpose
RetailerProbeWorkerDailyBatch checks all enabled catalogs
OxylabsResultWorkerOn webhookProcesses individual webhook results

Oxylabs credentials stored in Rails credentials:

Heatwave::Configuration.fetch(:oxylabs, :api_username)
Heatwave::Configuration.fetch(:oxylabs, :api_password)

Catalog IDs defined in CatalogConstants:

CatalogConstants::HOME_DEPOT_USA # => 1
CatalogConstants::AMAZON_SELLER_USA # => 5
CatalogConstants::RONA_CA # => 22
# etc.
# Via worker (recommended)
RetailerProbeWorker.perform_async(catalog_item_id: 12345)
# Via service directly
checker = Retailer::PriceChecker.new
result = checker.check(CatalogItem.find(12345))
RetailerProbeWorker.perform_async(catalog_id: 18)
RetailerProbeWorker.perform_async
# Via scope
ViewProductCatalog.vendor_catalogs.map_violations
# Via search
search = ProductCatalogSearch.create!(
query_params: { map_violation_eq: true, catalog_item_state_in: ['active'] }
)

Rona requires URL discovery due to JS-rendered search results:

# Discover and save URLs for all Rona items without URLs
Retailer::Extractors::Rona.seed_catalog_urls
# Or via rake task
bundle exec rake retailer:discover_rona_urls
  • Webhook endpoint requires valid JWT token
  • Tokens expire after 24 hours
  • Tokens are signed with Rails.application.secret_key_base
  • Invalid tokens return 401 Unauthorized
  1. Create extractor class in app/services/retailer/extractors/:
class Retailer::Extractors::NewRetailer < Retailer::Extractors::Base
def self.build_payload(url:)
{ source: 'universal', url: url, render: 'html' }
end
def extract(check, content)
return unless valid_html?(content)
check.scraper_source = source_name
check.currency = 'USD' # or determine from catalog
doc = parse_html(content)
# Try JSON-LD first (most reliable)
extract_json_ld_price(check, doc)
# Fallback: retailer-specific selectors
if check.price.blank?
selectors = ['.price', '[data-price]', '[itemprop="price"]']
extract_price_from_selectors(check, doc, selectors)
end
check.product_available = check_availability(content)
check.raw_title = extract_title(doc)
end
end
  1. Add to Factory in app/services/retailer/extractors/factory.rb:
def extractor_class(catalog_id)
case catalog_id
when NEW_RETAILER_CATALOG_ID
Retailer::Extractors::NewRetailer
# ... other cases
end
end
  1. Add catalog constant in app/models/concerns/catalog_constants.rb if needed

  2. Add to CATALOG_RETAILER_TYPES in Retailer::PriceChecker for fetch method routing

  3. Add URL pattern to Retailer::UrlConstructor if retailer has predictable URL structure

  4. Override validate_product_identity if retailer returns data in a special format (like Amazon’s JSON)