@adobe/spacecat-shared-tokowaka-client 1.1.1 → 1.2.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.
@@ -10,6 +10,8 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import { removePatchesBySuggestionIds } from '../utils/patch-utils.js';
14
+
13
15
  /**
14
16
  * Base class for opportunity mappers
15
17
  * Each opportunity type should extend this class and implement the abstract methods
@@ -40,16 +42,17 @@ export default class BaseOpportunityMapper {
40
42
  }
41
43
 
42
44
  /**
43
- * Converts a suggestion to a Tokowaka patch
45
+ * Converts suggestions to Tokowaka patches
44
46
  * @abstract
45
- * @param {Object} _ - Suggestion entity with getId() and getData() methods
46
- * @param {string} __ - Opportunity ID
47
- * @returns {Object|null} - Patch object or null if conversion fails
47
+ * @param {string} _ - URL path for the suggestions
48
+ * @param {Array} __ - Array of suggestion entities for the same URL
49
+ * @param {string} ___ - Opportunity ID
50
+ * @returns {Array} - Array of Tokowaka patch objects
48
51
  */
49
52
  // eslint-disable-next-line no-unused-vars
50
- suggestionToPatch(_, __) {
51
- this.log.error('suggestionToPatch() must be implemented by subclass');
52
- throw new Error('suggestionToPatch() must be implemented by subclass');
53
+ suggestionsToPatches(_, __, ___) {
54
+ this.log.error('suggestionsToPatches() must be implemented by subclass');
55
+ throw new Error('suggestionsToPatches() must be implemented by subclass');
53
56
  }
54
57
 
55
58
  /**
@@ -73,8 +76,17 @@ export default class BaseOpportunityMapper {
73
76
  * @returns {Object} - Base patch object
74
77
  */
75
78
  createBasePatch(suggestion, opportunityId) {
76
- const updatedAt = suggestion.getUpdatedAt();
77
- const lastUpdated = updatedAt ? new Date(updatedAt).getTime() : Date.now();
79
+ const data = suggestion.getData();
80
+ const updatedAt = data?.scrapedAt
81
+ || data?.transformRules?.scrapedAt
82
+ || suggestion.getUpdatedAt();
83
+
84
+ // Parse timestamp, fallback to Date.now() if invalid
85
+ let lastUpdated = Date.now();
86
+ if (updatedAt) {
87
+ const parsed = new Date(updatedAt).getTime();
88
+ lastUpdated = Number.isNaN(parsed) ? Date.now() : parsed;
89
+ }
78
90
 
79
91
  return {
80
92
  opportunityId,
@@ -83,4 +95,24 @@ export default class BaseOpportunityMapper {
83
95
  lastUpdated,
84
96
  };
85
97
  }
98
+
99
+ /**
100
+ * Removes patches from configuration for given suggestions
101
+ * Default implementation simply removes patches matching the suggestion IDs.
102
+ * Override this method in subclasses if custom rollback logic is needed
103
+ * (e.g., FAQ mapper removes heading patch when no suggestions remain).
104
+ * @param {Object} config - Current Tokowaka configuration
105
+ * @param {Array<string>} suggestionIds - Suggestion IDs to remove
106
+ * @param {string} opportunityId - Opportunity ID
107
+ * @returns {Object} - Updated configuration with patches removed
108
+ */
109
+ // eslint-disable-next-line no-unused-vars
110
+ rollbackPatches(config, suggestionIds, opportunityId) {
111
+ if (!config || !config.patches) {
112
+ return config;
113
+ }
114
+
115
+ this.log.debug(`Removing patches for ${suggestionIds.length} suggestions`);
116
+ return removePatchesBySuggestionIds(config, suggestionIds);
117
+ }
86
118
  }
@@ -10,11 +10,10 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import { toHast } from 'mdast-util-to-hast';
14
- import { fromMarkdown } from 'mdast-util-from-markdown';
15
13
  import { hasText } from '@adobe/spacecat-shared-utils';
16
14
  import { TARGET_USER_AGENTS_CATEGORIES } from '../constants.js';
17
15
  import BaseOpportunityMapper from './base-mapper.js';
16
+ import { markdownToHast } from '../utils/markdown-utils.js';
18
17
 
19
18
  /**
20
19
  * Mapper for content opportunity
@@ -37,43 +36,47 @@ export default class ContentSummarizationMapper extends BaseOpportunityMapper {
37
36
  }
38
37
 
39
38
  /**
40
- * Converts markdown text to HAST (Hypertext Abstract Syntax Tree) format
41
- * @param {string} markdown - Markdown text
42
- * @returns {Object} - HAST object
39
+ * Converts suggestions to Tokowaka patches
40
+ * @param {string} urlPath - URL path for the suggestions
41
+ * @param {Array} suggestions - Array of suggestion entities for the same URL
42
+ * @param {string} opportunityId - Opportunity ID
43
+ * @returns {Array} - Array of Tokowaka patch objects
43
44
  */
44
- // eslint-disable-next-line class-methods-use-this
45
- markdownToHast(markdown) {
46
- const mdast = fromMarkdown(markdown);
47
- return toHast(mdast);
48
- }
45
+ suggestionsToPatches(urlPath, suggestions, opportunityId) {
46
+ const patches = [];
49
47
 
50
- suggestionToPatch(suggestion, opportunityId) {
51
- const eligibility = this.canDeploy(suggestion);
52
- if (!eligibility.eligible) {
53
- this.log.warn(`Content-Summarization suggestion ${suggestion.getId()} cannot be deployed: ${eligibility.reason}`);
54
- return null;
55
- }
48
+ suggestions.forEach((suggestion) => {
49
+ const eligibility = this.canDeploy(suggestion);
50
+ if (!eligibility.eligible) {
51
+ this.log.warn(`Content-Summarization suggestion ${suggestion.getId()} cannot be deployed: ${eligibility.reason}`);
52
+ return;
53
+ }
56
54
 
57
- const data = suggestion.getData();
58
- const { summarizationText, transformRules } = data;
59
-
60
- // Convert markdown to HAST
61
- let hastValue;
62
- try {
63
- hastValue = this.markdownToHast(summarizationText);
64
- } catch (error) {
65
- this.log.error(`Failed to convert markdown to HAST for suggestion ${suggestion.getId()}: ${error.message}`);
66
- return null;
67
- }
55
+ const data = suggestion.getData();
56
+ const { summarizationText, transformRules } = data;
57
+
58
+ // Convert markdown to HAST
59
+ let hastValue;
60
+ try {
61
+ hastValue = markdownToHast(summarizationText);
62
+ } catch (error) {
63
+ this.log.error(`Failed to convert markdown to HAST for suggestion ${suggestion.getId()}: ${error.message}`);
64
+ return;
65
+ }
66
+
67
+ const patch = {
68
+ ...this.createBasePatch(suggestion, opportunityId),
69
+ op: transformRules.action,
70
+ selector: transformRules.selector,
71
+ value: hastValue,
72
+ valueFormat: 'hast',
73
+ target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
74
+ };
75
+
76
+ patches.push(patch);
77
+ });
68
78
 
69
- return {
70
- ...this.createBasePatch(suggestion, opportunityId),
71
- op: transformRules.action,
72
- selector: transformRules.selector,
73
- value: hastValue,
74
- valueFormat: 'hast',
75
- target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
76
- };
79
+ return patches;
77
80
  }
78
81
 
79
82
  /**
@@ -0,0 +1,247 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { hasText, isValidUrl } from '@adobe/spacecat-shared-utils';
14
+ import { TARGET_USER_AGENTS_CATEGORIES } from '../constants.js';
15
+ import BaseOpportunityMapper from './base-mapper.js';
16
+ import { markdownToHast } from '../utils/markdown-utils.js';
17
+ import { removePatchesBySuggestionIds } from '../utils/patch-utils.js';
18
+
19
+ /**
20
+ * Mapper for FAQ opportunity
21
+ * Handles conversion of FAQ suggestions to Tokowaka patches
22
+ */
23
+ export default class FaqMapper extends BaseOpportunityMapper {
24
+ constructor(log) {
25
+ super(log);
26
+ this.opportunityType = 'faq';
27
+ this.prerenderRequired = true;
28
+ this.validActions = ['insertAfter', 'insertBefore', 'appendChild'];
29
+ }
30
+
31
+ getOpportunityType() {
32
+ return this.opportunityType;
33
+ }
34
+
35
+ requiresPrerender() {
36
+ return this.prerenderRequired;
37
+ }
38
+
39
+ /**
40
+ * Builds FAQ item HTML structure (div with h3 and answer)
41
+ * Structure: <div><h3>question</h3>answer-content</div>
42
+ * @param {Object} suggestion - Suggestion entity
43
+ * @returns {Object} - HAST object for the FAQ item
44
+ * @private
45
+ */
46
+ // eslint-disable-next-line class-methods-use-this
47
+ buildFaqItemHast(suggestion) {
48
+ const data = suggestion.getData();
49
+ const { item } = data;
50
+
51
+ // Convert answer markdown to HAST
52
+ const answerHast = markdownToHast(item.answer);
53
+
54
+ // Build structure: <div><h3>question</h3>answer-hast-children</div>
55
+ return {
56
+ type: 'element',
57
+ tagName: 'div',
58
+ properties: {},
59
+ children: [
60
+ {
61
+ type: 'element',
62
+ tagName: 'h3',
63
+ properties: {},
64
+ children: [{ type: 'text', value: item.question }],
65
+ },
66
+ ...answerHast.children, // Spread answer HAST children directly
67
+ ],
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Creates individual patches for FAQ suggestions
73
+ * Always creates heading (h2) patch with latest timestamp, then individual FAQ divs
74
+ * @param {string} urlPath - URL path for current suggestions
75
+ * @param {Array} suggestions - Array of suggestion entities for the same URL (to be deployed)
76
+ * @param {string} opportunityId - Opportunity ID
77
+ * @returns {Array} - Array of patch objects
78
+ */
79
+ suggestionsToPatches(
80
+ urlPath,
81
+ suggestions,
82
+ opportunityId,
83
+ ) {
84
+ if (!urlPath || !Array.isArray(suggestions) || suggestions.length === 0) {
85
+ this.log.error('Invalid parameters for FAQ mapper.suggestionsToPatches');
86
+ return [];
87
+ }
88
+
89
+ // Filter eligible suggestions
90
+ const eligibleSuggestions = suggestions.filter((suggestion) => {
91
+ const eligibility = this.canDeploy(suggestion);
92
+ if (!eligibility.eligible) {
93
+ this.log.warn(`FAQ suggestion ${suggestion.getId()} cannot be deployed: ${eligibility.reason}`);
94
+ return false;
95
+ }
96
+ return true;
97
+ });
98
+
99
+ if (eligibleSuggestions.length === 0) {
100
+ this.log.warn('No eligible FAQ suggestions to deploy');
101
+ return [];
102
+ }
103
+
104
+ const patches = [];
105
+
106
+ // Get transformRules and headingText from first suggestion
107
+ const firstSuggestion = eligibleSuggestions[0];
108
+ const firstData = firstSuggestion.getData();
109
+ const { headingText = 'FAQs', transformRules } = firstData;
110
+
111
+ // Calculate the most recent lastUpdated from all eligible suggestions
112
+ // The heading patch should have the same timestamp as the newest FAQ
113
+ const maxLastUpdated = Math.max(...eligibleSuggestions.map((suggestion) => {
114
+ const data = suggestion.getData();
115
+ const updatedAt = data?.scrapedAt
116
+ || data?.transformRules?.scrapedAt
117
+ || suggestion.getUpdatedAt();
118
+
119
+ if (updatedAt) {
120
+ const parsed = new Date(updatedAt).getTime();
121
+ return Number.isNaN(parsed) ? Date.now() : parsed;
122
+ }
123
+ return Date.now();
124
+ }));
125
+
126
+ // Always create/update heading patch with latest timestamp
127
+ // mergePatches will replace existing heading if it already exists
128
+ this.log.debug(`Creating/updating heading patch for ${urlPath}`);
129
+
130
+ const headingHast = {
131
+ type: 'element',
132
+ tagName: 'h2',
133
+ properties: {},
134
+ children: [{ type: 'text', value: headingText }],
135
+ };
136
+
137
+ patches.push({
138
+ opportunityId,
139
+ // No suggestionId for FAQ heading patch
140
+ prerenderRequired: this.requiresPrerender(),
141
+ lastUpdated: maxLastUpdated,
142
+ op: transformRules.action,
143
+ selector: transformRules.selector,
144
+ value: headingHast,
145
+ valueFormat: 'hast',
146
+ target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
147
+ });
148
+
149
+ // Create individual FAQ patches
150
+ eligibleSuggestions.forEach((suggestion) => {
151
+ try {
152
+ const faqItemHast = this.buildFaqItemHast(suggestion);
153
+
154
+ patches.push({
155
+ ...this.createBasePatch(suggestion, opportunityId),
156
+ op: transformRules.action,
157
+ selector: transformRules.selector,
158
+ value: faqItemHast,
159
+ valueFormat: 'hast',
160
+ target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
161
+ });
162
+ } catch (error) {
163
+ this.log.error(`Failed to build FAQ HAST for suggestion ${suggestion.getId()}: ${error.message}`);
164
+ }
165
+ });
166
+
167
+ return patches;
168
+ }
169
+
170
+ /**
171
+ * Checks if a FAQ suggestion can be deployed
172
+ * @param {Object} suggestion - Suggestion object
173
+ * @returns {Object} { eligible: boolean, reason?: string }
174
+ */
175
+ canDeploy(suggestion) {
176
+ const data = suggestion.getData();
177
+
178
+ // Check shouldOptimize flag first
179
+ if (data?.shouldOptimize !== true) {
180
+ return { eligible: false, reason: 'shouldOptimize flag is not true' };
181
+ }
182
+
183
+ if (!data?.item?.question || !data?.item?.answer) {
184
+ return { eligible: false, reason: 'item.question and item.answer are required' };
185
+ }
186
+
187
+ if (!data.transformRules) {
188
+ return { eligible: false, reason: 'transformRules is required' };
189
+ }
190
+
191
+ if (!hasText(data.transformRules.selector)) {
192
+ return { eligible: false, reason: 'transformRules.selector is required' };
193
+ }
194
+
195
+ if (!this.validActions.includes(data.transformRules.action)) {
196
+ return { eligible: false, reason: 'transformRules.action must be insertAfter, insertBefore, or appendChild' };
197
+ }
198
+
199
+ if (!isValidUrl(data.url)) {
200
+ return { eligible: false, reason: `url ${data.url} is not a valid URL` };
201
+ }
202
+
203
+ return { eligible: true };
204
+ }
205
+
206
+ /**
207
+ * Removes patches from configuration for FAQ suggestions
208
+ * FAQ-specific logic: Also removes the heading patch when no FAQ suggestions remain
209
+ * @param {Object} config - Current Tokowaka configuration
210
+ * @param {Array<string>} suggestionIds - Suggestion IDs to remove
211
+ * @param {string} opportunityId - Opportunity ID
212
+ * @returns {Object} - Updated configuration with patches removed
213
+ */
214
+ rollbackPatches(config, suggestionIds, opportunityId) {
215
+ if (!config || !config.patches) {
216
+ return config;
217
+ }
218
+
219
+ const suggestionIdsSet = new Set(suggestionIds);
220
+ const additionalPatchKeys = [];
221
+
222
+ // Find FAQ patches for this opportunity
223
+ const opportunityPatches = config.patches.filter((p) => p.opportunityId === opportunityId);
224
+
225
+ // Get FAQ suggestion IDs that will remain after rollback
226
+ const remainingSuggestionIds = opportunityPatches
227
+ .filter((p) => p.suggestionId && !suggestionIdsSet.has(p.suggestionId))
228
+ .map((p) => p.suggestionId);
229
+
230
+ // If no FAQ suggestions remain, remove the heading patch too
231
+ if (remainingSuggestionIds.length === 0) {
232
+ this.log.debug('No remaining FAQ suggestions, marking heading patch for removal');
233
+ // Add heading patch key (opportunityId only, no suggestionId)
234
+ additionalPatchKeys.push(opportunityId);
235
+ } else {
236
+ this.log.debug(`${remainingSuggestionIds.length} FAQ suggestions remain, keeping heading patch`);
237
+ }
238
+
239
+ // Remove FAQ suggestion patches and any orphaned heading patches
240
+ this.log.debug(
241
+ `Removing ${suggestionIds.length} FAQ suggestion patches `
242
+ + `and ${additionalPatchKeys.length} heading patches`,
243
+ );
244
+
245
+ return removePatchesBySuggestionIds(config, suggestionIds, additionalPatchKeys);
246
+ }
247
+ }
@@ -33,30 +33,44 @@ export default class HeadingsMapper extends BaseOpportunityMapper {
33
33
  return this.prerenderRequired;
34
34
  }
35
35
 
36
- suggestionToPatch(suggestion, opportunityId) {
37
- const eligibility = this.canDeploy(suggestion);
38
- if (!eligibility.eligible) {
39
- this.log.warn(`Headings suggestion ${suggestion.getId()} cannot be deployed: ${eligibility.reason}`);
40
- return null;
41
- }
36
+ /**
37
+ * Converts suggestions to Tokowaka patches
38
+ * @param {string} urlPath - URL path for the suggestions
39
+ * @param {Array} suggestions - Array of suggestion entities for the same URL
40
+ * @param {string} opportunityId - Opportunity ID
41
+ * @returns {Array} - Array of Tokowaka patch objects
42
+ */
43
+ suggestionsToPatches(urlPath, suggestions, opportunityId) {
44
+ const patches = [];
42
45
 
43
- const data = suggestion.getData();
44
- const { checkType, transformRules } = data;
45
-
46
- const patch = {
47
- ...this.createBasePatch(suggestion, opportunityId),
48
- op: transformRules.action,
49
- selector: transformRules.selector,
50
- value: data.recommendedAction,
51
- valueFormat: 'text',
52
- ...(data.currentValue !== null && { currValue: data.currentValue }),
53
- target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
54
- };
55
-
56
- if (checkType === 'heading-missing-h1' && transformRules.tag) {
57
- patch.tag = transformRules.tag;
58
- }
59
- return patch;
46
+ suggestions.forEach((suggestion) => {
47
+ const eligibility = this.canDeploy(suggestion);
48
+ if (!eligibility.eligible) {
49
+ this.log.warn(`Headings suggestion ${suggestion.getId()} cannot be deployed: ${eligibility.reason}`);
50
+ return;
51
+ }
52
+
53
+ const data = suggestion.getData();
54
+ const { checkType, transformRules } = data;
55
+
56
+ const patch = {
57
+ ...this.createBasePatch(suggestion, opportunityId),
58
+ op: transformRules.action,
59
+ selector: transformRules.selector,
60
+ value: data.recommendedAction,
61
+ valueFormat: 'text',
62
+ ...(data.currentValue !== null && { currValue: data.currentValue }),
63
+ target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
64
+ };
65
+
66
+ if (checkType === 'heading-missing-h1' && transformRules.tag) {
67
+ patch.tag = transformRules.tag;
68
+ }
69
+
70
+ patches.push(patch);
71
+ });
72
+
73
+ return patches;
60
74
  }
61
75
 
62
76
  /**
@@ -12,6 +12,7 @@
12
12
 
13
13
  import HeadingsMapper from './headings-mapper.js';
14
14
  import ContentSummarizationMapper from './content-summarization-mapper.js';
15
+ import FaqMapper from './faq-mapper.js';
15
16
 
16
17
  /**
17
18
  * Registry for opportunity mappers
@@ -32,6 +33,7 @@ export default class MapperRegistry {
32
33
  const defaultMappers = [
33
34
  HeadingsMapper,
34
35
  ContentSummarizationMapper,
36
+ FaqMapper,
35
37
  // more mappers here
36
38
  ];
37
39