Class: Feed::Google::GoogleProductPresenter

Inherits:
Www::ProductCatalogPresenter
  • Object
show all
Includes:
Memery
Defined in:
app/services/feed/google/google_product_presenter.rb

Overview

Service object: google product presenter.

Direct Known Subclasses

OpenaiAds::ProductPresenter

Constant Summary collapse

MIN_IMAGE_EDGE =

Google Merchant's minimum image size is 500×500 (enforced 2027-01-31).
Ultra-wide images served at w-1280 can land under 500px tall.

500
FEED_IMAGE_WIDTH =
1280
CUSTOM_LABEL_1_BY_ROOT_LINE =

Google Shopping / Performance Max listing groups subdivide inventory by a
custom label, then by item id. A product missing its bucket lands in the
campaigns' excluded "everything else" node and stops serving — which is what
happened when the Merchant API feed replaced the legacy feed that carried
these labels (all Shopping/PMax went to zero impressions on 2026-06-30).

Two labels, on purpose:

  • custom_label_0 = the root product line slug (e.g. "floor-heating"): a
    stable, feed-owned key. The durable grouping dimension — it can't drift out
    of the feed again and needs no hand-maintained mapping.
  • custom_label_1 = the bucket string the live campaigns partition on today,
    so serving resumes without rewriting their listing groups. A few buckets
    rename our line (Towel Warmer → "towel warmers", Infrared Heating Panels →
    "radiant heat panels"), so map explicitly and fall back to the root label
    words for lines no campaign partitions on.

ponytail: custom_label_1 exists only to match today's campaigns; once they
re-partition on custom_label_0's slug it can be dropped.

{
  'floor_heating' => 'floor heating',
  'snow_melting' => 'snow melting',
  'towel_warmer' => 'towel warmers',
  'countertop_heater' => 'countertop heaters',
  'pipe_freeze_protection' => 'pipe freeze protection',
  'infrared_heating_panels' => 'radiant heat panels',
  'roof_and_gutter_deicing' => 'roof & gutter deicing'
}.freeze

Delegated Instance Attributes collapse

Instance Method Summary collapse

Instance Method Details



63
64
65
# File 'app/services/feed/google/google_product_presenter.rb', line 63

def additional_image_links
  (all_images(max_images: 10) || []).map { |i| feed_image_url(i) }
end

#all_images(max_images: nil) ⇒ Object



43
44
45
46
# File 'app/services/feed/google/google_product_presenter.rb', line 43

def all_images(max_images: nil)
  images = feed_images
  max_images.present? ? images.first(max_images) : images
end

#availabilityObject



186
187
188
# File 'app/services/feed/google/google_product_presenter.rb', line 186

def availability
  product_stock_status.tableize.singularize
end

#brandObject



178
179
180
# File 'app/services/feed/google/google_product_presenter.rb', line 178

def brand
  'WarmlyYours'
end

#cache_keyObject



278
279
280
# File 'app/services/feed/google/google_product_presenter.rb', line 278

def cache_key
  "google_catalog_item_feed/#{r.cache_key}/#{r.item.cache_key}"
end

#color_infoObject



249
250
251
252
253
# File 'app/services/feed/google/google_product_presenter.rb', line 249

def color_info
  return unless has_variants?

  item.spec_output('color') || item.spec_output('finish')
end

#conditionObject



166
167
168
# File 'app/services/feed/google/google_product_presenter.rb', line 166

def condition
  item_condition
end

#custom_label_0Object

Root product line slug, hyphenated (e.g. "floor-heating") — the stable key.



141
142
143
# File 'app/services/feed/google/google_product_presenter.rb', line 141

def custom_label_0
  root_line_slug&.tr('_', '-')
end

#custom_label_1Object

Marketing bucket the live campaigns partition on (see the note above).



146
147
148
149
150
# File 'app/services/feed/google/google_product_presenter.rb', line 146

def custom_label_1
  return if root_line_slug.blank?

  CUSTOM_LABEL_1_BY_ROOT_LINE[root_line_slug] || root_line_slug.tr('_', ' ')
end

#dealer_channel_only_product?Boolean

Returns:

  • (Boolean)


30
31
32
# File 'app/services/feed/google/google_product_presenter.rb', line 30

def dealer_channel_only_product?
  product_catalog_presenter.display_where_to_buy? || product_catalog_presenter.display_dealers?
end

#descriptionObject



162
163
164
# File 'app/services/feed/google/google_product_presenter.rb', line 162

def description
  catalog_item.effective_seo_description&.first(5000)
end

#feed_image_url(image) ⇒ Object

Serve at w-1280; when the scaled height would fall under the 500px
minimum, also request h-500 so ImageKit pads only the short edge
(Imageable#image_url defaults to cm-pad_resize on a white background
whenever both dimensions are given).



71
72
73
74
75
76
77
78
79
# File 'app/services/feed/google/google_product_presenter.rb', line 71

def feed_image_url(image)
  return unless image

  options = { encode_format: :webp, width: FEED_IMAGE_WIDTH }
  w = image.attachment_width
  h = image.attachment_height
  options[:height] = MIN_IMAGE_EDGE if w&.positive? && h && (h * FEED_IMAGE_WIDTH / w) < MIN_IMAGE_EDGE
  image.image_url(**options)
end

#feed_imagesObject



49
50
51
# File 'app/services/feed/google/google_product_presenter.rb', line 49

def feed_images
  item.google_feed_image_profiles.map(&:image)
end

#google_product_categoryObject



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'app/services/feed/google/google_product_presenter.rb', line 90

def google_product_category
  pl_ltree = item_primary_product_line_slug_ltree&.to_s
  pc_url = product_category_url
  return 499_932 if /power-relay-panels/.match?(pc_url) # Hardware > Power & Electrical Supplies > Electrical Switches > Specialty Electrical Switches & Relays
  return 1897   if /thermostats/.match?(pc_url) # Hardware  | Heating, Ventilation & Air Conditioning  | HVAC Controls  | Thermostats
  return 1519   if pc_url =~ /controls/ || pl_ltree&.start_with?('third_party_control_integration') # Hardware  | Heating, Ventilation & Air Conditioning  | HVAC Controls
  return 2729   if pl_ltree&.start_with?('countertop_heater') && pc_url =~ /heating-elements/ # Hardware	Building Materials	Countertops
  return 499_873 if pl_ltree&.start_with?('floor_heating') # | Hardware  | Heating, Ventilation & Air Conditioning
  return 595    if pl_ltree&.start_with?('mirror') # Home & Garden	Decor	Mirrors
  return 2060   if pl_ltree&.start_with?('infrared_heating_panels') # | Home & Garden  | Household Appliances  | Climate Control Appliances  | Heating Radiators
  return 4544   if pl_ltree&.include?('roof_and_gutter_deicing') # Hardware	Building Materials	Roofing	Gutter Accessories
  return 5362   if pl_ltree&.start_with?('snow_melting') #	Home & Garden	Lawn & Garden	Snow Removal
  return 586    if pl_ltree&.start_with?('towel_warmer') # | Home & Garden  | Bathroom Accessories  | Towel Racks & Holders
  return 122 if /insulation/.match?(pc_url) #  Hardware	Building Materials	Insulation

  115 #	Hardware > Building Materials
end

#has_variants?Boolean

Returns:

  • (Boolean)


199
200
201
202
203
# File 'app/services/feed/google/google_product_presenter.rb', line 199

def has_variants?
  return .has_variants if 

  variants.exists?
end

#idObject

Alias for R#id

Returns:

  • (Object)

    R#id

See Also:



13
# File 'app/services/feed/google/google_product_presenter.rb', line 13

delegate :id, :item, to: :r


59
60
61
# File 'app/services/feed/google/google_product_presenter.rb', line 59

def image_link
  feed_image_url(all_images.first)
end

#itemObject

Alias for R#item

Returns:

  • (Object)

    R#item

See Also:



13
# File 'app/services/feed/google/google_product_presenter.rb', line 13

delegate :id, :item, to: :r

#item_group_nameObject



206
207
208
209
210
211
# File 'app/services/feed/google/google_product_presenter.rb', line 206

def item_group_name
  return unless has_variants?
  return .group_name if 

  r.item.item_grouping_info&.item_group_name
end

#mpnObject



182
183
184
# File 'app/services/feed/google/google_product_presenter.rb', line 182

def mpn
  item_sku
end

#output_shipping_dimension(n, unit, precision: 0) ⇒ Object



255
256
257
258
259
260
# File 'app/services/feed/google/google_product_presenter.rb', line 255

def output_shipping_dimension(n, unit, precision: 0)
  return unless n

  sn = ActionController::Base.helpers.number_with_precision n.ceil, precision: precision, strip_insignificant_zeros: true
  "#{sn} #{unit}"
end

#pickup_slaObject



195
196
197
# File 'app/services/feed/google/google_product_presenter.rb', line 195

def pickup_sla
  'same day'
end

#price_with_currencyObject



170
171
172
# File 'app/services/feed/google/google_product_presenter.rb', line 170

def price_with_currency
  r.catalog_item.money_price.format(with_currency: true, symbol: false, thousands_separator: '')
end

#product_catalog_presenterObject



15
16
17
# File 'app/services/feed/google/google_product_presenter.rb', line 15

def product_catalog_presenter
  @product_catalog_presenter ||= Www::ProductCatalogPresenter.new(view_product_catalog)
end

#product_typeObject



108
109
110
# File 'app/services/feed/google/google_product_presenter.rb', line 108

def product_type
  [r.product_category_lineage_expanded, r.item_primary_product_line_lineage_expanded].compact.join(',')
end

#public_specificationsObject



85
86
87
# File 'app/services/feed/google/google_product_presenter.rb', line 85

def public_specifications
  product_specifications.reject { |_k, v| v[:visibility] == 'private' }
end

#reported_name_for_googleObject

Alias for Catalog_item#reported_name_for_google

Returns:

  • (Object)

    Catalog_item#reported_name_for_google

See Also:



9
# File 'app/services/feed/google/google_product_presenter.rb', line 9

delegate :variants, :reported_name_for_google, to: :catalog_item

#root_line_slugObject

First label of the primary product line's slug ltree (underscore form, e.g.
"floor_heating"); nil for products without a product line.



154
155
156
# File 'app/services/feed/google/google_product_presenter.rb', line 154

def root_line_slug
  item_primary_product_line_slug_ltree&.to_s&.split('.')&.first
end

#sale_price_date_rangeObject



282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'app/services/feed/google/google_product_presenter.rb', line 282

def sale_price_date_range
  return nil if sale_price_effective_date.blank?

  start_date = sale_price_effective_date.beginning_of_day.iso8601
  end_date = if sale_price_expiration_date.present?
               sale_price_expiration_date.end_of_day.iso8601
             else
               # Default to end of current month if no expiration date
               Date.current.end_of_month.end_of_day.iso8601
             end

  "#{start_date}/#{end_date}"
end

#sale_price_with_currencyObject



174
175
176
# File 'app/services/feed/google/google_product_presenter.rb', line 174

def sale_price_with_currency
  r.catalog_item.money_sale_price.format(with_currency: true, symbol: false, thousands_separator: '')
end

#seo_keywordsObject



34
35
36
# File 'app/services/feed/google/google_product_presenter.rb', line 34

def seo_keywords
  item.seo_keywords.presence || product_line_page_keywords(item.primary_product_line)
end

#shipping_heightObject



274
275
276
# File 'app/services/feed/google/google_product_presenter.rb', line 274

def shipping_height
  output_shipping_dimension r.shipping_height_in, 'in'
end

#shipping_lengthObject



270
271
272
# File 'app/services/feed/google/google_product_presenter.rb', line 270

def shipping_length
  output_shipping_dimension r.shipping_length_in, 'in'
end

#shipping_weightObject



262
263
264
# File 'app/services/feed/google/google_product_presenter.rb', line 262

def shipping_weight
  output_shipping_dimension r.shipping_weight_lbs, 'lb'
end

#shipping_widthObject



266
267
268
# File 'app/services/feed/google/google_product_presenter.rb', line 266

def shipping_width
  output_shipping_dimension r.shipping_width_in, 'in'
end

#ships_from_country_isoObject



296
297
298
# File 'app/services/feed/google/google_product_presenter.rb', line 296

def ships_from_country_iso
  options[:country_iso] || r.catalog_item.catalog.country&.iso
end

#size_infoObject



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'app/services/feed/google/google_product_presenter.rb', line 219

def size_info
  return unless has_variants?

  # Combination of our variant axes for google
  si = spec_output('size').presence
  si ||= spec_output('size_overall').presence
  si ||= begin
    si_tokens = []
    use_tokens = variant_axis_tokens.presence
    separator = ', '
    if use_tokens.nil?
      use_tokens = %i[width length]
      separator = ' x '
    end
    # We can only use token related to size
    use_tokens.select { |t| t.in?(%i[width length]) }.each do |token|
      si_tokens << spec_output(token)
    rescue StandardError
      nil
    end
    si_tokens = si_tokens.filter_map(&:presence)
    si_tokens.presence&.join(separator)
  end
  si ||= spec_output('coverage').presence
  si ||= spec_output('watts').presence

  si ||= title.first(100) # Worst possible size info
  si
end

#spec_outputObject

Alias for R#spec_output

Returns:

  • (Object)

    R#spec_output

See Also:



10
# File 'app/services/feed/google/google_product_presenter.rb', line 10

delegate :spec_output, to: :r

#store_codeObject



38
39
40
41
# File 'app/services/feed/google/google_product_presenter.rb', line 38

def store_code
  # This has to match what's in My Google BUsiness Advanced options
  store_item.store.short_name
end

#titleObject



158
159
160
# File 'app/services/feed/google/google_product_presenter.rb', line 158

def title
  catalog_item.reported_name&.first(150)
end

#valid_for_google?(include_image_check: true) ⇒ Boolean

Returns:

  • (Boolean)


19
20
21
22
23
24
25
26
27
28
# File 'app/services/feed/google/google_product_presenter.rb', line 19

def valid_for_google?(include_image_check: true)
  url.present? &&
    (shipping_weight_lbs&.> 0) &&
    (shipping_height_in&.> 0) &&
    (shipping_length_in&.> 0) &&
    (shipping_width_in&.> 0) &&
    !dealer_channel_only_product? &&
    (item_condition == 'new') &&
    (include_image_check == false || (include_image_check && all_images.present?))
end

#variant_axis_tokensObject



213
214
215
216
217
# File 'app/services/feed/google/google_product_presenter.rb', line 213

def variant_axis_tokens
  return .axis_tokens if 

  super
end

#variantsObject

Alias for Catalog_item#variants

Returns:

  • (Object)

    Catalog_item#variants

See Also:



9
# File 'app/services/feed/google/google_product_presenter.rb', line 9

delegate :variants, :reported_name_for_google, to: :catalog_item

#videosObject



81
82
83
# File 'app/services/feed/google/google_product_presenter.rb', line 81

def videos
  Item::VideoRetriever.new(tags: 'for-product-page').process(item).all_videos
end