huginn_acumen_product_agent 1.6.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/huginn_acumen_product_agent.rb +8 -1
- data/lib/huginn_acumen_product_agent/acumen_agent_error.rb +9 -0
- data/lib/huginn_acumen_product_agent/acumen_client.rb +1 -0
- data/lib/huginn_acumen_product_agent/acumen_product_agent.rb +139 -33
- data/lib/huginn_acumen_product_agent/concerns/acumen_query_concern.rb +58 -0
- data/lib/huginn_acumen_product_agent/concerns/agent_error_concern.rb +19 -0
- data/lib/huginn_acumen_product_agent/concerns/alternate_products_query_concern.rb +87 -0
- data/lib/huginn_acumen_product_agent/concerns/inv_product_query_concern.rb +108 -0
- data/lib/huginn_acumen_product_agent/concerns/prod_mkt_query_concern.rb +163 -0
- data/lib/huginn_acumen_product_agent/concerns/product_categories_query_concern.rb +74 -0
- data/lib/huginn_acumen_product_agent/concerns/product_contributors_query_concern.rb +116 -0
- metadata +10 -3
- data/lib/huginn_acumen_product_agent/concerns/acumen_product_query_concern.rb +0 -456
@@ -0,0 +1,108 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module is responsible for reading/processing data recrods from the Inv_Product
|
4
|
+
# table in Acumen. This table contains the baseline product information -- name, sku,
|
5
|
+
# format, etc.
|
6
|
+
module InvProductQueryConcern
|
7
|
+
extend AcumenQueryConcern
|
8
|
+
|
9
|
+
# Fetch/Process the Acumen data,
|
10
|
+
def fetch_inv_product_data(acumen_client, product_ids, digital_format_list)
|
11
|
+
product_data = acumen_client.get_products(product_ids)
|
12
|
+
|
13
|
+
return process_inv_product_response(product_data, digital_format_list)
|
14
|
+
end
|
15
|
+
|
16
|
+
# This function returns an array of Acumen products mapped to Schema.org/Product
|
17
|
+
# objects. We've added additional fields of:
|
18
|
+
#
|
19
|
+
# * `isAvailableForPurchase` -- used to control product deletion in external systems
|
20
|
+
# * `acumenAttributes` -- Additional acumen data that doesn't have a direct 1:1 field
|
21
|
+
# on the Product, but may be useful in other platforms
|
22
|
+
def process_inv_product_response(raw_data, digital_format_list)
|
23
|
+
raw_data.map do |p|
|
24
|
+
|
25
|
+
begin
|
26
|
+
product = response_mapper(p, {
|
27
|
+
'Inv_Product.ID' => 'identifier',
|
28
|
+
'Inv_Product.ProdCode' => 'sku',
|
29
|
+
'Inv_Product.Full_Title' => 'name',
|
30
|
+
'Inv_Product.SubTitle' => 'disambiguatingDescription',
|
31
|
+
'Inv_Product.ISBN_UPC' => 'isbn',
|
32
|
+
'Inv_Product.Pub_Date' => 'datePublished',
|
33
|
+
'Inv_Product.Next_Release' => 'releaseDate',
|
34
|
+
})
|
35
|
+
|
36
|
+
product['@type'] = 'Product'
|
37
|
+
product['isTaxable'] = get_field_value(p, 'Inv_Product.Taxable') == '1'
|
38
|
+
product['isAvailableForPurchase'] = get_field_value(p, 'Inv_Product.Not_On_Website') == '0'
|
39
|
+
product['acumenAttributes'] = {
|
40
|
+
'info_alpha_1' => get_field_value(p, 'Inv_Product.Info_Alpha_1'),
|
41
|
+
'info_boolean_1' => get_field_value(p, 'Inv_Product.Info_Boolean_1'), # is_available_on_formed
|
42
|
+
}
|
43
|
+
product['additionalProperty'] = [
|
44
|
+
{
|
45
|
+
'@type' => 'PropertyValue',
|
46
|
+
'propertyID' => 'is_master',
|
47
|
+
'value' => get_field_value(p, 'Inv_Product.OnWeb_LinkOnly') == '0',
|
48
|
+
}
|
49
|
+
]
|
50
|
+
|
51
|
+
product['offers'] = [{
|
52
|
+
'@type' => 'Offer',
|
53
|
+
'price' => get_field_value(p, 'Inv_Product.Price_1'),
|
54
|
+
'availability' => get_field_value(p, 'Inv_Product.BO_Reason')
|
55
|
+
}]
|
56
|
+
|
57
|
+
if get_field_value(p, 'Inv_Product.Price_2')
|
58
|
+
product['offers'].push({
|
59
|
+
'@type' => 'Offer',
|
60
|
+
'price' => get_field_value(p, 'Inv_Product.Price_2'),
|
61
|
+
'availability' => get_field_value(p, 'Inv_Product.BO_Reason')
|
62
|
+
})
|
63
|
+
end
|
64
|
+
|
65
|
+
weight = get_field_value(p, 'Inv_Product.Weight')
|
66
|
+
product['weight'] = get_quantitative_value(weight, 'oz.')
|
67
|
+
|
68
|
+
# The category used here is the Acumen Product category. Functionally, this
|
69
|
+
# serves as the product's _format_. This field is different from Web Categories
|
70
|
+
# which behave more like traditional category taxonomies.
|
71
|
+
category = get_field_value(p, 'Inv_Product.Category')
|
72
|
+
if category
|
73
|
+
product['acumenAttributes']['category'] = category
|
74
|
+
product['isDigital'] = digital_format_list.find { |f| f == category } ? true : false
|
75
|
+
|
76
|
+
if category == 'Paperback'
|
77
|
+
product['additionalType'] = 'Book'
|
78
|
+
product['bookFormat'] = "http://schema.org/Paperback"
|
79
|
+
product['accessMode'] = "textual"
|
80
|
+
elsif category == 'Hardcover'
|
81
|
+
product['additionalType'] = 'Book'
|
82
|
+
product['bookFormat'] = "http://schema.org/Hardcover"
|
83
|
+
product['accessMode'] = "textual"
|
84
|
+
elsif category == 'eBook'
|
85
|
+
product['additionalType'] = 'Book'
|
86
|
+
product['bookFormat'] = "http://schema.org/EBook"
|
87
|
+
product['accessMode'] = "textual"
|
88
|
+
elsif category == 'CD'
|
89
|
+
product['additionalType'] = 'CreativeWork'
|
90
|
+
product['accessMode'] = "auditory"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
product
|
95
|
+
|
96
|
+
rescue => error
|
97
|
+
issue_error(AcumenAgentError.new(
|
98
|
+
'process_inv_product_response',
|
99
|
+
'Failed to load Inventory Product Records',
|
100
|
+
{ product_ids: product_ids },
|
101
|
+
error,
|
102
|
+
))
|
103
|
+
|
104
|
+
return
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,163 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module is responsible for reading/processing the ProdMkt table. This table
|
4
|
+
# contains more detailed product meta information: Publisher, page counts, age range,
|
5
|
+
# description, etc.
|
6
|
+
module ProdMktQueryConcern
|
7
|
+
extend AcumenQueryConcern
|
8
|
+
|
9
|
+
# Update the provided products with their associated marketing data
|
10
|
+
# NOTE: The `products` here are Shema.org/Product records mapped from Inv_Product
|
11
|
+
# data
|
12
|
+
def fetch_product_marketing(acumen_client, products)
|
13
|
+
|
14
|
+
product_ids = products.map { |p| p['identifier'] }
|
15
|
+
marketing_data = acumen_client.get_products_marketing(product_ids)
|
16
|
+
marketing_data = process_prod_mkt_response(marketing_data)
|
17
|
+
|
18
|
+
return map_marketing_data(products, marketing_data)
|
19
|
+
end
|
20
|
+
|
21
|
+
# This function parses the raw data returned from the Prod_Mkt table
|
22
|
+
def process_prod_mkt_response(raw_data)
|
23
|
+
results = {}
|
24
|
+
raw_data.map do |product_marketing|
|
25
|
+
|
26
|
+
begin
|
27
|
+
mapped = response_mapper(product_marketing, {
|
28
|
+
'ProdMkt.Product_ID' => 'product_id',
|
29
|
+
'ProdMkt.Product_Code' => 'sku',
|
30
|
+
'ProdMkt.ID' => 'id',
|
31
|
+
'ProdMkt.Pages' => 'pages',
|
32
|
+
'ProdMkt.Publisher' => 'publisher',
|
33
|
+
'ProdMkt.Description_Short' => 'description_short',
|
34
|
+
'ProdMkt.Description_Long' => 'description_long',
|
35
|
+
'ProdMkt.Height' => 'height',
|
36
|
+
'ProdMkt.Width' => 'width',
|
37
|
+
'ProdMkt.Thickness' => 'depth',
|
38
|
+
'ProdMkt.Meta_Keywords' => 'meta_keywords',
|
39
|
+
'ProdMkt.Meta_Description' => 'meta_description',
|
40
|
+
'ProdMkt.Extent_Unit' => 'extent_unit',
|
41
|
+
'ProdMkt.Extent_Value' => 'extent_value',
|
42
|
+
'ProdMkt.Age_Highest' => 'age_highest',
|
43
|
+
'ProdMkt.Age_Lowest' => 'age_lowest',
|
44
|
+
'ProdMkt.Awards' => 'awards',
|
45
|
+
'ProdMkt.Dimensions_Unit_Measure' => 'dimensions_unit_measure',
|
46
|
+
'ProdMkt.Excerpt' => 'excerpt',
|
47
|
+
'ProdMkt.Grade_Highest' => 'grade_highest',
|
48
|
+
'ProdMkt.Grade_Lowest' => 'grade_lowest',
|
49
|
+
'ProdMkt.Status' => 'status',
|
50
|
+
'ProdMkt.UPC' => 'upc',
|
51
|
+
'ProdMkt.Weight_Unit_Measure' => 'weight_unit_measure',
|
52
|
+
'ProdMkt.Weight' => 'weight',
|
53
|
+
'ProdMkt.Info_Text_01' => 'info_text_01',
|
54
|
+
'ProdMkt.Info_Text_02' => 'info_text_02',
|
55
|
+
'ProdMkt.Religious_Text_Identifier' => 'religious_text_identifier',
|
56
|
+
'ProdMkt.Info_Alpha_07' => 'info_alpha_07',
|
57
|
+
})
|
58
|
+
|
59
|
+
results[mapped['product_id']] = mapped
|
60
|
+
# NOTE: In this case, product_id matches the Inv_Product.ID fields
|
61
|
+
rescue => error
|
62
|
+
issue_error(AcumenAgentError.new(
|
63
|
+
'process_prod_mkt_response',
|
64
|
+
'Failed while processing Prod_Mkt record',
|
65
|
+
product_marketing,
|
66
|
+
error,
|
67
|
+
))
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
results
|
72
|
+
end
|
73
|
+
|
74
|
+
# This function maps parsed Prod_Mkt records to their matching product record
|
75
|
+
# and updates the product object with the additional data
|
76
|
+
def map_marketing_data(products, marketing_data)
|
77
|
+
products.map do |product|
|
78
|
+
marketing = marketing_data[product['identifier']]
|
79
|
+
|
80
|
+
begin
|
81
|
+
|
82
|
+
if marketing
|
83
|
+
product['acumenAttributes']['product_marketing_id'] = marketing['id']
|
84
|
+
|
85
|
+
product['publisher'] = {
|
86
|
+
'@type': 'Organization',
|
87
|
+
'name' => marketing['publisher']
|
88
|
+
};
|
89
|
+
product['description'] = marketing['description_long']
|
90
|
+
product['abstract'] = marketing['description_short']
|
91
|
+
product['keywords'] = marketing['meta_keywords']
|
92
|
+
product['text'] = marketing['excerpt']
|
93
|
+
|
94
|
+
if marketing['age_lowest'] && marketing['age_highest']
|
95
|
+
product['typicalAgeRange'] = "#{marketing['age_lowest']}-#{marketing['age_highest']}"
|
96
|
+
end
|
97
|
+
|
98
|
+
#---------- Product Page Attributes ----------#
|
99
|
+
if marketing['grade_lowest'] || marketing['grade_highest']
|
100
|
+
# educationalUse? educationalAlignment?
|
101
|
+
product['additionalProperty'].push({
|
102
|
+
'@type' => 'PropertyValue',
|
103
|
+
'name' => 'Grade',
|
104
|
+
'propertyID' => 'grade_range',
|
105
|
+
'minValue' => marketing['grade_lowest'],
|
106
|
+
'maxValue' => marketing['grade_highest'],
|
107
|
+
'value' => "#{marketing['grade_lowest']}-#{marketing['grade_highest']}",
|
108
|
+
})
|
109
|
+
end
|
110
|
+
|
111
|
+
if marketing['awards']
|
112
|
+
product['additionalProperty'].push({
|
113
|
+
'@type' => 'PropertyValue',
|
114
|
+
'propertyID' => 'awards',
|
115
|
+
'name' => 'Awards',
|
116
|
+
'value' => marketing['awards'],
|
117
|
+
})
|
118
|
+
end
|
119
|
+
|
120
|
+
#---------- Acumen Specific Properties ----------#
|
121
|
+
product['acumenAttributes']['extent_unit'] = marketing['extent_unit']
|
122
|
+
product['acumenAttributes']['extent_value'] = marketing['extent_value']
|
123
|
+
product['acumenAttributes']['info_text_01'] = marketing['info_text_01'] # editorial_reviews
|
124
|
+
product['acumenAttributes']['info_text_02'] = marketing['info_text_02'] # product_samples
|
125
|
+
product['acumenAttributes']['info_alpha_07'] = marketing['info_alpha_07'] # video_urls
|
126
|
+
product['acumenAttributes']['meta_description'] = marketing['meta_description']
|
127
|
+
product['acumenAttributes']['religious_text_identifier'] = marketing['religious_text_identifier']
|
128
|
+
product['acumenAttributes']['status'] = marketing['status']
|
129
|
+
|
130
|
+
product['gtin12'] = marketing['upc']
|
131
|
+
product['numberOfPages'] = marketing['pages']
|
132
|
+
|
133
|
+
product['height'] = get_quantitative_value(
|
134
|
+
marketing['height'], marketing['dimensions_unit_measure']
|
135
|
+
)
|
136
|
+
product['width'] = get_quantitative_value(
|
137
|
+
marketing['width'], marketing['dimensions_unit_measure']
|
138
|
+
)
|
139
|
+
product['depth'] = get_quantitative_value(
|
140
|
+
marketing['thickness'], marketing['dimensions_unit_measure']
|
141
|
+
)
|
142
|
+
if product['weight']['value'] == '0'
|
143
|
+
product['weight'] = get_quantitative_value(
|
144
|
+
marketing['weight'], marketing['weight_unit_measure']
|
145
|
+
)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
rescue => error
|
150
|
+
issue_error(AcumenAgentError.new(
|
151
|
+
'map_marketing_data',
|
152
|
+
'Failed to map marketing data for product',
|
153
|
+
{ product: product, marketing: marketing },
|
154
|
+
error,
|
155
|
+
))
|
156
|
+
end
|
157
|
+
|
158
|
+
product
|
159
|
+
end
|
160
|
+
|
161
|
+
return products
|
162
|
+
end
|
163
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module is responsible for reading/processing data from the ProdMkt_WPC table
|
4
|
+
# NOTE: Records on this table are linked to products by SKU
|
5
|
+
module ProductCategoriesQueryConcern
|
6
|
+
extend AcumenQueryConcern
|
7
|
+
|
8
|
+
# Updates the provided products with their associated category data
|
9
|
+
def fetch_product_categories(acumen_client, products)
|
10
|
+
|
11
|
+
product_skus = products.map { |p| p['sku'] }
|
12
|
+
category_data = acumen_client.get_product_categories(product_skus)
|
13
|
+
category_data = process_product_category_response(category_data)
|
14
|
+
|
15
|
+
return map_category_data(products, category_data)
|
16
|
+
end
|
17
|
+
|
18
|
+
# This function parses the raw data returned from the ProdMkt_WPC table
|
19
|
+
# This table holds the relationship between products and Web Categories
|
20
|
+
# The resulting data contains arrays of Category records keyed to product SKUs
|
21
|
+
#
|
22
|
+
# NOTE: In the Acumen data, some Category assignments are marked as inactive.
|
23
|
+
# This function only returns _active_ categories in the resulting data.
|
24
|
+
def process_product_category_response(raw_data)
|
25
|
+
results = {}
|
26
|
+
|
27
|
+
raw_data.map do |product_category|
|
28
|
+
|
29
|
+
begin
|
30
|
+
mapped = response_mapper(product_category, {
|
31
|
+
'ProdMkt_WPC.ProdCode' => 'sku',
|
32
|
+
'ProdMkt_WPC.WPC_ID' => 'category_id',
|
33
|
+
'ProdMkt_WPC.Inactive' => 'inactive',
|
34
|
+
})
|
35
|
+
|
36
|
+
product_sku = mapped['sku']
|
37
|
+
|
38
|
+
if results[product_sku]
|
39
|
+
results[product_sku].push(mapped) if mapped['inactive'] == '0'
|
40
|
+
else
|
41
|
+
results[product_sku] = [mapped] if mapped['inactive'] == '0'
|
42
|
+
end
|
43
|
+
rescue => error
|
44
|
+
issue_errpr(AcumenAgentError.new(
|
45
|
+
'process_product_category_response',
|
46
|
+
'Failed while processing category data',
|
47
|
+
product_category,
|
48
|
+
error,
|
49
|
+
))
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
results
|
54
|
+
end
|
55
|
+
|
56
|
+
# This function maps parsed Web Category records to their matching Product
|
57
|
+
def map_category_data(products, category_data)
|
58
|
+
products.each do |product|
|
59
|
+
product['categories'] = []
|
60
|
+
categories = category_data[product['sku']]
|
61
|
+
|
62
|
+
unless categories.nil?
|
63
|
+
categories.map do |cat|
|
64
|
+
product['categories'].push({
|
65
|
+
'@type' => 'Thing',
|
66
|
+
'identifier' => cat['category_id']
|
67
|
+
})
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
return products
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module is responsible for reading/processing product Contributor data.
|
4
|
+
#
|
5
|
+
# The data in question here comes from multiple tables. The `ProdMkt_Contrib_Link`
|
6
|
+
# table defines the _relationship_ between products and contributors, and the
|
7
|
+
# `ProdMkt_Contributor` table defines the _contribution type_ (Author, Editor, etc)
|
8
|
+
module ProductContributorsQueryConcern
|
9
|
+
extend AcumenQueryConcern
|
10
|
+
|
11
|
+
# Updates the provided products with their associared contributor data
|
12
|
+
def fetch_product_contributors(acumen_client, products)
|
13
|
+
|
14
|
+
marketing_ids = products.map { |p| p['acumenAttributes']['product_marketing_id'] }
|
15
|
+
contributor_data = acumen_client.get_product_contributors(marketing_ids)
|
16
|
+
contributor_data = process_product_contributor_response(contributor_data)
|
17
|
+
|
18
|
+
contributor_ids = contributor_data.map { |c| c['contributor_id'] }
|
19
|
+
contributor_type_data = acumen_client.get_contributor_types(contributor_ids)
|
20
|
+
contributor_type_data = process_contributor_type_response(contributor_type_data)
|
21
|
+
|
22
|
+
return map_contributor_data(products, contributor_data, contributor_type_data)
|
23
|
+
end
|
24
|
+
|
25
|
+
# This function parses the raw data returned from the ProdMkt_Contrib_Link table
|
26
|
+
# This table holds the relationship between products and Contributors
|
27
|
+
# The resulting data is a hash mapping contributor arrays to Prod_Mkt.ID values
|
28
|
+
def process_product_contributor_response(raw_data)
|
29
|
+
contributors = []
|
30
|
+
|
31
|
+
raw_data.each do |contributor|
|
32
|
+
|
33
|
+
begin
|
34
|
+
mapped = response_mapper(contributor, {
|
35
|
+
'ProdMkt_Contrib_Link.ProdMkt_Contrib_ID' => 'contributor_id',
|
36
|
+
'ProdMkt_Contrib_Link.ProdMkt_ID' => 'product_marketing_id',
|
37
|
+
'ProdMkt_Contrib_Link.Inactive' => 'inactive',
|
38
|
+
})
|
39
|
+
|
40
|
+
if mapped['inactive'] == '0'
|
41
|
+
contributors.push(mapped)
|
42
|
+
end
|
43
|
+
rescue => error
|
44
|
+
issue_error(AcumenAgentError.new(
|
45
|
+
'process_product_contributor_response',
|
46
|
+
'Failed while processing contributor record',
|
47
|
+
contributor,
|
48
|
+
error,
|
49
|
+
))
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
return contributors
|
54
|
+
end
|
55
|
+
|
56
|
+
# This function parses the raw data returned from the ProdMkt_Contributor table
|
57
|
+
# This table holds the contributor type (e.g. Author) for the
|
58
|
+
# contributor/product relationship
|
59
|
+
def process_contributor_type_response(raw_data)
|
60
|
+
results = {}
|
61
|
+
raw_data.map do |contributor_type|
|
62
|
+
|
63
|
+
begin
|
64
|
+
mapped = response_mapper(contributor_type, {
|
65
|
+
'ProdMkt_Contributor.ID' => 'contributor_id',
|
66
|
+
'ProdMkt_Contributor.Contrib_Type' => 'type',
|
67
|
+
})
|
68
|
+
|
69
|
+
|
70
|
+
if !results[mapped['contributor_id']]
|
71
|
+
results[mapped['contributor_id']] = mapped['type']
|
72
|
+
end
|
73
|
+
rescue => error
|
74
|
+
issue_error(AcumenAgentError.new(
|
75
|
+
'process_contributor_type_response',
|
76
|
+
'Failed while processing contributor type record',
|
77
|
+
contributor_type,
|
78
|
+
error,
|
79
|
+
))
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
return results
|
84
|
+
end
|
85
|
+
|
86
|
+
# This function maps parsed Contributor records to their matching Inv_Product record
|
87
|
+
def map_contributor_data(products, contributor_data, contributor_type_data)
|
88
|
+
products.each do |product|
|
89
|
+
|
90
|
+
begin
|
91
|
+
marketing_id = product['acumenAttributes']['product_marketing_id']
|
92
|
+
contributors = contributor_data.select { |c| c['product_marketing_id'] == marketing_id }
|
93
|
+
|
94
|
+
product['contributors'] = contributors.map do |c|
|
95
|
+
{
|
96
|
+
'@type' => 'Person',
|
97
|
+
'identifier' => c['contributor_id'],
|
98
|
+
'acumenAttributes' => {
|
99
|
+
'contrib_type' => contributor_type_data[c['contributor_id']]
|
100
|
+
}
|
101
|
+
}
|
102
|
+
end
|
103
|
+
rescue => error
|
104
|
+
issue_error(AcumenAgentError.new(
|
105
|
+
'map_contributor_data',
|
106
|
+
'Failed while mapping contributor data to products',
|
107
|
+
{ product: product, contributors: contributor_data, contributor_types: contributor_type_data },
|
108
|
+
error
|
109
|
+
))
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
return products
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|