huginn_acumen_product_agent 1.7.3 → 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_product_agent.rb +134 -45
- 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 -511
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 30c918a4763fbc8b84145f83215cf46d9b162adeb32d025d206d7f7fa51ded41
|
4
|
+
data.tar.gz: 77b5d58d8de8f15e3908b920b04882fb0706974009c9fa1ea1fe93bb66b7f1d6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e2fe59ce492e910b0ca2c063938141a9f0ef6b64639fff28f34c3e01d577e5388d6007e1a135a51f01579677e60f2ee0e79e3aa2a449bfffba0e44242258953a
|
7
|
+
data.tar.gz: 92563dd015ec11f237a6a91d740d3ff682ddcb73e6598f1768c3dfa51c2c4e75cb8355c3ee7228a5f63a0f1470a483d21adb5bd324283f4efa45b7c77ccada50
|
@@ -1,6 +1,13 @@
|
|
1
1
|
require 'huginn_agent'
|
2
2
|
|
3
|
-
HuginnAgent.load 'huginn_acumen_product_agent/concerns/
|
3
|
+
HuginnAgent.load 'huginn_acumen_product_agent/concerns/acumen_query_concern'
|
4
|
+
HuginnAgent.load 'huginn_acumen_product_agent/concerns/alternate_products_query_concern'
|
5
|
+
HuginnAgent.load 'huginn_acumen_product_agent/concerns/inv_product_query_concern'
|
6
|
+
HuginnAgent.load 'huginn_acumen_product_agent/concerns/prod_mkt_query_concern'
|
7
|
+
HuginnAgent.load 'huginn_acumen_product_agent/concerns/product_categories_query_concern'
|
8
|
+
HuginnAgent.load 'huginn_acumen_product_agent/concerns/product_contributors_query_concern'
|
9
|
+
|
4
10
|
HuginnAgent.load 'huginn_acumen_product_agent/acumen_client'
|
11
|
+
HuginnAgent.load 'huginn_acumen_product_agent/acumen_agent_error'
|
5
12
|
|
6
13
|
HuginnAgent.register 'huginn_acumen_product_agent/acumen_product_agent'
|
@@ -3,7 +3,12 @@
|
|
3
3
|
module Agents
|
4
4
|
class AcumenProductAgent < Agent
|
5
5
|
include WebRequestConcern
|
6
|
-
include
|
6
|
+
include AcumenQueryConcern
|
7
|
+
include InvProductQueryConcern
|
8
|
+
include ProdMktQueryConcern
|
9
|
+
include ProductContributorsQueryConcern
|
10
|
+
include ProductCategoriesQueryConcern
|
11
|
+
include AlternateProductsQueryConcern
|
7
12
|
|
8
13
|
default_schedule '12h'
|
9
14
|
|
@@ -11,27 +16,86 @@ module Agents
|
|
11
16
|
default_schedule 'never'
|
12
17
|
|
13
18
|
description <<-MD
|
14
|
-
|
19
|
+
Huginn agent for retrieving sane ACUMEN product data.
|
15
20
|
|
16
|
-
|
17
|
-
|
21
|
+
## Agent Options
|
22
|
+
The following outlines the available options in this agent
|
18
23
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
24
|
+
### Acumen Connection
|
25
|
+
* endpoint: The root URL for the Acumen API
|
26
|
+
* site_code: The site code from Acumen
|
27
|
+
* password: The Acumen API password
|
23
28
|
|
24
|
-
|
25
|
-
|
26
|
-
* digital_formats: A list of the formats associated with a digital product
|
29
|
+
### Format Options
|
30
|
+
* digital_formats: A list of the formats associated with a digital product
|
27
31
|
|
28
|
-
|
29
|
-
|
30
|
-
|
32
|
+
### Product Attributes
|
33
|
+
* attribute_to_property: An optional map linking Acumen attributes to Schema.org
|
34
|
+
product properties.
|
31
35
|
|
32
|
-
|
33
|
-
|
34
|
-
|
36
|
+
### Other Options
|
37
|
+
* ignore_skus: An optional array of Acumen product skus that will be intentionally
|
38
|
+
excluded from any output.
|
39
|
+
|
40
|
+
### Event Output
|
41
|
+
This agent will output one of two event types during processing:
|
42
|
+
|
43
|
+
* Product bundles
|
44
|
+
* Processing Errors
|
45
|
+
|
46
|
+
The product bundle payload will be structured as:
|
47
|
+
|
48
|
+
```
|
49
|
+
{
|
50
|
+
products: [ { ... }, { ... }, ... ],
|
51
|
+
status: 200
|
52
|
+
}
|
53
|
+
```
|
54
|
+
|
55
|
+
The processing error payload will be structured as:
|
56
|
+
|
57
|
+
```
|
58
|
+
{
|
59
|
+
status: 500,
|
60
|
+
scope: '[Process Name]',
|
61
|
+
message: '[Error Message]',
|
62
|
+
data: { ... },
|
63
|
+
trace: [ ... ]
|
64
|
+
}
|
65
|
+
```
|
66
|
+
|
67
|
+
### Payload Status
|
68
|
+
|
69
|
+
`status: 200`: Indicates a true success. The agent has output the full
|
70
|
+
range of expected data.
|
71
|
+
|
72
|
+
`status: 206`: Indicates a partial success. The products within the bundle
|
73
|
+
are vaild, but the bundle _may_ be missing products that were somehow invalid.
|
74
|
+
|
75
|
+
`status: 500`: Indicates a processing error. This may represent a complete
|
76
|
+
process failure, but may also be issued in parallel to a `202` payload.
|
77
|
+
|
78
|
+
Because this agent receives an array of Product IDs as input, errors will be issued in
|
79
|
+
such a way that product processing can recover when possible. Errors that occur within
|
80
|
+
a specific product bundle will emit an error event, but the agent will then move
|
81
|
+
forward processing the next bundle.
|
82
|
+
|
83
|
+
For example, if this agent receives two products as input (`A` and `B`), and we fail to
|
84
|
+
load the Inv_Product record for product `A`, the agent would emit an error payload of:
|
85
|
+
|
86
|
+
```
|
87
|
+
{
|
88
|
+
status: 500,
|
89
|
+
scope: 'Fetch Inv_Product Data',
|
90
|
+
message: 'Failed to lookup Inv_Product record for Product A',
|
91
|
+
data: { product_id: 123 },
|
92
|
+
trace: [ ... ]
|
93
|
+
}
|
94
|
+
```
|
95
|
+
|
96
|
+
The goal of this approach is to ensure the agent outputs as much data as reasonably possible
|
97
|
+
with each execution. If there is an error in the Paperback version of a title, that shouldn't
|
98
|
+
prevent this agent from returning the Hardcover version.
|
35
99
|
|
36
100
|
MD
|
37
101
|
|
@@ -40,7 +104,6 @@ module Agents
|
|
40
104
|
'endpoint' => 'https://example.com',
|
41
105
|
'site_code' => '',
|
42
106
|
'password' => '',
|
43
|
-
'physical_formats' => [],
|
44
107
|
'digital_formats' => [],
|
45
108
|
'attribute_to_property' => {},
|
46
109
|
}
|
@@ -59,10 +122,6 @@ module Agents
|
|
59
122
|
errors.add(:base, 'password is a required field')
|
60
123
|
end
|
61
124
|
|
62
|
-
unless options['physical_formats'].present?
|
63
|
-
errors.add(:base, "physical_formats is a required field")
|
64
|
-
end
|
65
|
-
|
66
125
|
unless options['digital_formats'].present?
|
67
126
|
errors.add(:base, "digital_formats is a required field")
|
68
127
|
end
|
@@ -72,9 +131,9 @@ module Agents
|
|
72
131
|
end
|
73
132
|
|
74
133
|
if options['ignore_skus']
|
75
|
-
|
76
|
-
|
77
|
-
|
134
|
+
unless options['ignore_skus'].is_a?(Array)
|
135
|
+
errors.add(:base, "if provided, ignore_skus must be an array")
|
136
|
+
end
|
78
137
|
end
|
79
138
|
end
|
80
139
|
|
@@ -95,13 +154,14 @@ module Agents
|
|
95
154
|
private
|
96
155
|
|
97
156
|
def handle(event)
|
157
|
+
# Process agent options
|
98
158
|
endpoint = interpolated['endpoint']
|
99
159
|
site_code = interpolated['site_code']
|
100
160
|
password = interpolated['password']
|
101
|
-
physical_formats = interpolated['physical_formats']
|
102
161
|
digital_formats = interpolated['digital_formats']
|
103
|
-
|
162
|
+
ignored_skus = interpolated['ignore_skus'] ? interpolated['ignore_skus'] : []
|
104
163
|
|
164
|
+
# Configure the Acumen Client
|
105
165
|
auth = {
|
106
166
|
'site_code' => site_code,
|
107
167
|
'password' => password,
|
@@ -110,33 +170,62 @@ module Agents
|
|
110
170
|
client = AcumenClient.new(faraday, auth)
|
111
171
|
|
112
172
|
ids = event.payload['ids']
|
113
|
-
products = get_products_by_ids(client, ids)
|
114
|
-
products = get_product_variants(client, products, physical_formats, digital_formats)
|
115
|
-
products = get_master_products_by_id(client, products)
|
116
|
-
products = get_product_categories(client, products)
|
117
|
-
products = get_product_contributors(client, products)
|
118
|
-
|
119
|
-
# map attributes
|
120
|
-
products.map do |product|
|
121
|
-
map_attributes(product)
|
122
173
|
|
123
|
-
|
124
|
-
|
125
|
-
|
174
|
+
# Load Products
|
175
|
+
fetch_product_bundles(client, ids, digital_formats, ignored_skus)
|
176
|
+
end
|
126
177
|
|
127
|
-
|
128
|
-
|
178
|
+
private
|
179
|
+
|
180
|
+
# Returns an array of Product objects for the provided product_ids.
|
181
|
+
# Each object is a merged representation of all the individual Acumen tables
|
182
|
+
# that make up a product record with fields mapped to the schema.org/Product
|
183
|
+
# object definition.
|
184
|
+
def fetch_products(acumen_client, product_ids, digital_format_list)
|
185
|
+
products = fetch_inv_product_data(acumen_client, product_ids, digital_format_list)
|
186
|
+
products = fetch_product_marketing(acumen_client, products)
|
187
|
+
products = fetch_product_contributors(acumen_client, products)
|
188
|
+
products = fetch_product_categories(acumen_client, products)
|
129
189
|
|
130
190
|
products.each do |product|
|
131
|
-
|
132
|
-
create_event payload: product
|
133
|
-
end
|
191
|
+
map_attributes(product)
|
134
192
|
end
|
193
|
+
|
194
|
+
return products
|
135
195
|
end
|
136
196
|
|
137
|
-
|
197
|
+
# Returns an array of product bundles for the provided `products` array.
|
198
|
+
# Each bundle will contain an array of all the product definitions for each
|
199
|
+
# format of a given title.
|
200
|
+
#
|
201
|
+
# NOTE: The generated bundles will contain both active and inactive products
|
202
|
+
# to facilitate product deletion in external systems.
|
203
|
+
def fetch_product_bundles(acumen_client, product_ids, digital_format_list, ignored_skus)
|
138
204
|
|
205
|
+
begin
|
206
|
+
alternate_ids_map = fetch_alternate_format_ids(acumen_client, product_ids)
|
207
|
+
|
208
|
+
bundles = product_ids.map do |id|
|
209
|
+
bundle_ids = alternate_ids_map[id]
|
210
|
+
bundle_ids.append(id) unless bundle_ids.include?(id)
|
211
|
+
bundle = fetch_products(acumen_client, bundle_ids.sort, digital_format_list)
|
212
|
+
|
213
|
+
# Filter out any products that are explicitly ignored by SKU
|
214
|
+
bundle.select { |p| !ignored_skus.include?(p['sku']) }
|
215
|
+
|
216
|
+
create_event payload: { products: bundle, status: 200 }
|
217
|
+
end
|
218
|
+
rescue AcumenAgentError => e
|
219
|
+
issue_error(e)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
# Maps additional Acumen attributes to the `additionalProperty` array
|
224
|
+
# NOTE: Attributes mapped in this way will be _removed_ from the
|
225
|
+
# `acumenAttributes` array.
|
139
226
|
def map_attributes(product)
|
227
|
+
|
228
|
+
|
140
229
|
attribute_to_property = interpolated['attribute_to_property']
|
141
230
|
attributes = product['acumenAttributes']
|
142
231
|
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module contains the baseline utility methods used in the more specific
|
4
|
+
# data concerns
|
5
|
+
module AcumenQueryConcern
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
UNIT_MAP = {
|
9
|
+
'oz.' => 'OZ',
|
10
|
+
'Inches (US)' => 'INH',
|
11
|
+
}
|
12
|
+
|
13
|
+
protected
|
14
|
+
|
15
|
+
# Maps Acumen XML data to a hash object as specified in the provided `field_map`
|
16
|
+
# The field map is a hash of { source_field: target_field }
|
17
|
+
def response_mapper(data, field_map)
|
18
|
+
result = {}
|
19
|
+
|
20
|
+
field_map.each do |source_field, target_field|
|
21
|
+
result[target_field] = get_field_value(data, source_field)
|
22
|
+
end
|
23
|
+
|
24
|
+
return result
|
25
|
+
end
|
26
|
+
|
27
|
+
# Utility function to retrieve a value from an XML field
|
28
|
+
def get_field_value(data, field_name)
|
29
|
+
data[field_name]['__content__'] if data[field_name]
|
30
|
+
end
|
31
|
+
|
32
|
+
# Returns a quantitative field value (e.g. weight) as a Schema.org/QuantitativeValue
|
33
|
+
# object
|
34
|
+
def get_quantitative_value(value, unit)
|
35
|
+
{
|
36
|
+
'@type' => 'QuantitativeValue',
|
37
|
+
'value' => value,
|
38
|
+
'unitText' => unit,
|
39
|
+
'unitCode' => (UNIT_MAP[unit] if unit),
|
40
|
+
} if value
|
41
|
+
end
|
42
|
+
|
43
|
+
# Emits an error payload event to facilitate better debugging/logging
|
44
|
+
# NOTE: The `error` here is expected to be an instance of AcumenAgentError
|
45
|
+
def issue_error(error, status = 500)
|
46
|
+
# NOTE: Status is intentionally included on the top-level payload so that other
|
47
|
+
# agents can look for a `payload[:status]` of either 200 or 500 to distinguish
|
48
|
+
# between success and failure states
|
49
|
+
create_event payload: {
|
50
|
+
status: status,
|
51
|
+
scope: error.scope,
|
52
|
+
message: error.message,
|
53
|
+
original_error: error.original_error,
|
54
|
+
data: error.data,
|
55
|
+
trace: error.original_error.backtrace,
|
56
|
+
}
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AgentErrorConcern
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
def issue_error(error, status = 500)
|
7
|
+
# NOTE: Status is intentionally included on the top-level payload so that other
|
8
|
+
# agents can look for a `payload[:status]` of either 200 or 500 to distinguish
|
9
|
+
# between success and failure states
|
10
|
+
create_event payload: {
|
11
|
+
status: status,
|
12
|
+
scope: error.scope,
|
13
|
+
message: error.message,
|
14
|
+
original_error: error.original_error,
|
15
|
+
data: error.data,
|
16
|
+
trace: error.original_error.backtrace,
|
17
|
+
}
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module is responsible for reading/processing data from the Product_Link
|
4
|
+
# table. This table defines the link between product records.
|
5
|
+
#
|
6
|
+
# It's important to note that this table defines links between related products
|
7
|
+
# as well as product formats. The `Alt_Format` field determines whether the linked
|
8
|
+
# product is an alternate format for a given title.
|
9
|
+
#
|
10
|
+
# For the purposes of this concern, we only care about Product_Link records where
|
11
|
+
# the `Alt_Format` field is `true`
|
12
|
+
module AlternateProductsQueryConcern
|
13
|
+
extend AcumenQueryConcern
|
14
|
+
|
15
|
+
# Fetches the Inv_Product.ID for alternate formats of the provided product_ids
|
16
|
+
def fetch_alternate_format_ids(acumen_client, product_ids)
|
17
|
+
begin
|
18
|
+
link_data = acumen_client.get_linked_products(product_ids)
|
19
|
+
|
20
|
+
links = process_alternate_format_response(link_data)
|
21
|
+
|
22
|
+
return map_alternate_format_links(links, product_ids)
|
23
|
+
rescue => error
|
24
|
+
issue_error(AcumenAgentError.new(
|
25
|
+
'fetch_alternate_format_ids',
|
26
|
+
'Failed attempting to lookup alternate products',
|
27
|
+
product_ids,
|
28
|
+
error
|
29
|
+
))
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# This function parses the raw data returned from the Product_Link table
|
34
|
+
# The resulting array contains the set alternate format IDs associated with a
|
35
|
+
# single product
|
36
|
+
def process_alternate_format_response(raw_data)
|
37
|
+
results = []
|
38
|
+
raw_data.map do |link|
|
39
|
+
|
40
|
+
begin
|
41
|
+
mapped = response_mapper(link, {
|
42
|
+
'Product_Link.Link_From_ID' => 'from_id',
|
43
|
+
'Product_Link.Link_To_ID' => 'to_id',
|
44
|
+
'Product_Link.Alt_Format' => 'alt_format',
|
45
|
+
})
|
46
|
+
|
47
|
+
if mapped['alt_format'].to_s != '0' && !mapped.in?(results)
|
48
|
+
results.push(mapped)
|
49
|
+
end
|
50
|
+
|
51
|
+
rescue => error
|
52
|
+
issue_error(AcumenAgentError.new(
|
53
|
+
'process_alternate_format_response',
|
54
|
+
'Failed while processing alternate format links',
|
55
|
+
raw_data,
|
56
|
+
error
|
57
|
+
))
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
return results
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns a map that ties each provided `product_id` to an array of IDs for its
|
65
|
+
# other formats
|
66
|
+
def map_alternate_format_links(links, product_ids)
|
67
|
+
results = {}
|
68
|
+
|
69
|
+
product_ids.each do |id|
|
70
|
+
|
71
|
+
begin
|
72
|
+
alternates = links.select { |l| l['from_id'] == id }
|
73
|
+
results[id] = alternates.map { |l| l['to_id'] }
|
74
|
+
|
75
|
+
rescue => error
|
76
|
+
issue_error(AcumenAgentError.new(
|
77
|
+
'map_alternate_format_links',
|
78
|
+
'Failed while mapping alternate format links',
|
79
|
+
{ id: id, links: links },
|
80
|
+
error
|
81
|
+
))
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
return results
|
86
|
+
end
|
87
|
+
end
|