Class: Feed::Google::GoogleProductPresenter
- Inherits:
-
Www::ProductCatalogPresenter
- Object
- Www::ProductCatalogPresenter
- Feed::Google::GoogleProductPresenter
show all
- Includes:
- Memery
- Defined in:
- app/services/feed/google/google_product_presenter.rb
Overview
Service object: google product presenter.
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
#additional_image_links ⇒ Object
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
|
#availability ⇒ Object
186
187
188
|
# File 'app/services/feed/google/google_product_presenter.rb', line 186
def availability
product_stock_status.tableize.singularize
end
|
#brand ⇒ Object
178
179
180
|
# File 'app/services/feed/google/google_product_presenter.rb', line 178
def brand
'WarmlyYours'
end
|
#cache_key ⇒ Object
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_info ⇒ Object
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
|
#condition ⇒ Object
166
167
168
|
# File 'app/services/feed/google/google_product_presenter.rb', line 166
def condition
item_condition
end
|
#custom_label_0 ⇒ Object
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_1 ⇒ Object
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
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
|
#description ⇒ Object
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_images ⇒ Object
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_category ⇒ Object
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) return 1897 if /thermostats/.match?(pc_url) return 1519 if pc_url =~ /controls/ || pl_ltree&.start_with?('third_party_control_integration') return 2729 if pl_ltree&.start_with?('countertop_heater') && pc_url =~ /heating-elements/ return 499_873 if pl_ltree&.start_with?('floor_heating') return 595 if pl_ltree&.start_with?('mirror') return 2060 if pl_ltree&.start_with?('infrared_heating_panels') return 4544 if pl_ltree&.include?('roof_and_gutter_deicing') return 5362 if pl_ltree&.start_with?('snow_melting') return 586 if pl_ltree&.start_with?('towel_warmer') return 122 if /insulation/.match?(pc_url)
115 end
|
#has_variants? ⇒ Boolean
199
200
201
202
203
|
# File 'app/services/feed/google/google_product_presenter.rb', line 199
def has_variants?
return variant_metadata.has_variants if variant_metadata
variants.exists?
end
|
#id ⇒ Object
13
|
# File 'app/services/feed/google/google_product_presenter.rb', line 13
delegate :id, :item, to: :r
|
#image_link ⇒ Object
59
60
61
|
# File 'app/services/feed/google/google_product_presenter.rb', line 59
def image_link
feed_image_url(all_images.first)
end
|
#item ⇒ Object
13
|
# File 'app/services/feed/google/google_product_presenter.rb', line 13
delegate :id, :item, to: :r
|
#item_group_name ⇒ Object
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 variant_metadata.group_name if variant_metadata
r.item.item_grouping_info&.item_group_name
end
|
#mpn ⇒ Object
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_method ⇒ Object
191
192
193
|
# File 'app/services/feed/google/google_product_presenter.rb', line 191
def pickup_method
'buy'
end
|
#pickup_sla ⇒ Object
195
196
197
|
# File 'app/services/feed/google/google_product_presenter.rb', line 195
def pickup_sla
'same day'
end
|
#price_with_currency ⇒ Object
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_presenter ⇒ Object
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_type ⇒ Object
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_specifications ⇒ Object
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_google ⇒ Object
Alias for Catalog_item#reported_name_for_google
9
|
# File 'app/services/feed/google/google_product_presenter.rb', line 9
delegate :variants, :reported_name_for_google, to: :catalog_item
|
#root_line_slug ⇒ Object
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_range ⇒ Object
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
Date.current.end_of_month.end_of_day.iso8601
end
"#{start_date}/#{end_date}"
end
|
#sale_price_with_currency ⇒ Object
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_keywords ⇒ Object
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_height ⇒ Object
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_length ⇒ Object
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_weight ⇒ Object
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_width ⇒ Object
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_iso ⇒ Object
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_info ⇒ Object
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?
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
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) si
end
|
#spec_output ⇒ Object
10
|
# File 'app/services/feed/google/google_product_presenter.rb', line 10
delegate :spec_output, to: :r
|
#store_code ⇒ Object
38
39
40
41
|
# File 'app/services/feed/google/google_product_presenter.rb', line 38
def store_code
store_item.store.short_name
end
|
#title ⇒ Object
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
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_tokens ⇒ Object
213
214
215
216
217
|
# File 'app/services/feed/google/google_product_presenter.rb', line 213
def variant_axis_tokens
return variant_metadata.axis_tokens if variant_metadata
super
end
|
#variants ⇒ Object
Alias for Catalog_item#variants
9
|
# File 'app/services/feed/google/google_product_presenter.rb', line 9
delegate :variants, :reported_name_for_google, to: :catalog_item
|
#videos ⇒ Object
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
|