@adobe/spacecat-shared-tokowaka-client 1.1.0 → 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.
@@ -108,7 +108,7 @@ export default class BaseOpportunityMapper {
108
108
  */
109
109
  // eslint-disable-next-line no-unused-vars
110
110
  rollbackPatches(config, suggestionIds, opportunityId) {
111
- if (!config || !config.tokowakaOptimizations) {
111
+ if (!config || !config.patches) {
112
112
  return config;
113
113
  }
114
114
 
@@ -205,41 +205,36 @@ export default class FaqMapper extends BaseOpportunityMapper {
205
205
 
206
206
  /**
207
207
  * Removes patches from configuration for FAQ suggestions
208
- * FAQ-specific logic: Also removes the heading patch when no FAQ suggestions remain for a URL
208
+ * FAQ-specific logic: Also removes the heading patch when no FAQ suggestions remain
209
209
  * @param {Object} config - Current Tokowaka configuration
210
210
  * @param {Array<string>} suggestionIds - Suggestion IDs to remove
211
211
  * @param {string} opportunityId - Opportunity ID
212
212
  * @returns {Object} - Updated configuration with patches removed
213
213
  */
214
214
  rollbackPatches(config, suggestionIds, opportunityId) {
215
- if (!config || !config.tokowakaOptimizations) {
215
+ if (!config || !config.patches) {
216
216
  return config;
217
217
  }
218
218
 
219
219
  const suggestionIdsSet = new Set(suggestionIds);
220
220
  const additionalPatchKeys = [];
221
221
 
222
- // Analyze each URL to determine if FAQ heading patch should be removed
223
- Object.entries(config.tokowakaOptimizations).forEach(([urlPath, optimization]) => {
224
- const { patches = [] } = optimization;
225
-
226
- // Find FAQ patches for this opportunity
227
- const opportunityPatches = patches.filter((p) => p.opportunityId === opportunityId);
228
-
229
- // Get FAQ suggestion IDs that will remain after rollback (for this URL)
230
- const remainingSuggestionIds = opportunityPatches
231
- .filter((p) => p.suggestionId && !suggestionIdsSet.has(p.suggestionId))
232
- .map((p) => p.suggestionId);
233
-
234
- // If no FAQ suggestions remain for this URL, remove the heading patch too
235
- if (remainingSuggestionIds.length === 0) {
236
- this.log.debug(`No remaining FAQ suggestions for ${urlPath}, marking heading patch for removal`);
237
- // Add heading patch key (opportunityId only, no suggestionId)
238
- additionalPatchKeys.push(`${urlPath}:${opportunityId}`);
239
- } else {
240
- this.log.debug(`${remainingSuggestionIds.length} FAQ suggestions remain for ${urlPath}, keeping heading patch`);
241
- }
242
- });
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
+ }
243
238
 
244
239
  // Remove FAQ suggestion patches and any orphaned heading patches
245
240
  this.log.debug(
@@ -81,6 +81,7 @@ async function fetchWithRetry(url, options, maxRetries, retryDelayMs, log, fetch
81
81
  await sleep(retryDelayMs);
82
82
  }
83
83
  }
84
+ /* c8 ignore next */
84
85
  throw new Error(`Failed to fetch ${fetchType} HTML after ${maxRetries} retries`);
85
86
  }
86
87
 
@@ -67,50 +67,37 @@ export function mergePatches(existingPatches, newPatches) {
67
67
 
68
68
  /**
69
69
  * Removes patches matching the given suggestion IDs from a config
70
+ * Works with flat config structure
70
71
  * @param {Object} config - Tokowaka configuration object
71
72
  * @param {Array<string>} suggestionIds - Array of suggestion IDs to remove
72
73
  * @param {Array<string>} additionalPatchKeys - Optional array of additional patch keys to remove
73
74
  * @returns {Object} - Updated configuration with patches removed
74
75
  */
75
76
  export function removePatchesBySuggestionIds(config, suggestionIds, additionalPatchKeys = []) {
76
- if (!config || !config.tokowakaOptimizations) {
77
+ if (!config || !config.patches) {
77
78
  return config;
78
79
  }
79
80
 
80
81
  const suggestionIdSet = new Set(suggestionIds);
81
82
  const patchKeysToRemove = new Set(additionalPatchKeys);
82
- const updatedOptimizations = {};
83
83
  let removedCount = 0;
84
84
 
85
- // Iterate through each URL path and filter out patches matching suggestionIds or patch keys
86
- Object.entries(config.tokowakaOptimizations).forEach(([urlPath, optimization]) => {
87
- const { patches = [] } = optimization;
85
+ // Filter out patches with matching suggestionIds or additional patch keys
86
+ const filteredPatches = config.patches.filter((patch) => {
87
+ const shouldRemoveBySuggestionId = suggestionIdSet.has(patch.suggestionId);
88
+ const patchKey = getPatchKey(patch);
89
+ const shouldRemoveByPatchKey = patchKeysToRemove.has(patchKey);
88
90
 
89
- // Filter out patches with matching suggestionIds or patch keys
90
- const filteredPatches = patches.filter((patch) => {
91
- const shouldRemoveBySuggestionId = suggestionIdSet.has(patch.suggestionId);
92
- const patchKey = `${urlPath}:${getPatchKey(patch)}`;
93
- const shouldRemoveByPatchKey = patchKeysToRemove.has(patchKey);
94
-
95
- if (shouldRemoveBySuggestionId || shouldRemoveByPatchKey) {
96
- removedCount += 1;
97
- return false;
98
- }
99
- return true;
100
- });
101
-
102
- // Only keep URL paths that still have patches
103
- if (filteredPatches.length > 0) {
104
- updatedOptimizations[urlPath] = {
105
- ...optimization,
106
- patches: filteredPatches,
107
- };
91
+ if (shouldRemoveBySuggestionId || shouldRemoveByPatchKey) {
92
+ removedCount += 1;
93
+ return false;
108
94
  }
95
+ return true;
109
96
  });
110
97
 
111
98
  return {
112
99
  ...config,
113
- tokowakaOptimizations: updatedOptimizations,
100
+ patches: filteredPatches,
114
101
  removedCount,
115
102
  };
116
103
  }
@@ -11,14 +11,107 @@
11
11
  */
12
12
 
13
13
  /**
14
- * Generates S3 path for Tokowaka configuration
15
- * @param {string} apiKey - Tokowaka API key
14
+ * Normalizes a URL pathname for S3 storage
15
+ * - Removes trailing slash (except for root '/')
16
+ * - Ensures starts with '/'
17
+ * @param {string} pathname - URL pathname
18
+ * @returns {string} - Normalized pathname
19
+ */
20
+ export function normalizePath(pathname) {
21
+ let normalized = pathname.endsWith('/') && pathname !== '/' ? pathname.slice(0, -1) : pathname;
22
+
23
+ if (!normalized.startsWith('/')) {
24
+ normalized = `/${normalized}`;
25
+ }
26
+
27
+ return normalized;
28
+ }
29
+
30
+ /**
31
+ * Extracts and normalizes hostname from URL
32
+ * - Strips 'www.' prefix
33
+ * @param {URL} url - URL object
34
+ * @param {Object} logger - Logger instance
35
+ * @returns {string} - Normalized hostname
36
+ * @throws {Error} - If hostname extraction fails
37
+ */
38
+ export function getHostName(url, logger) {
39
+ try {
40
+ const finalHostname = url.hostname.replace(/^www\./, '');
41
+ return finalHostname;
42
+ } catch (error) {
43
+ logger.error(`Error extracting host name: ${error.message}`);
44
+ throw new Error(`Error extracting host name: ${url.toString()}`);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Base64 URL encodes a string (RFC 4648)
50
+ * - Uses URL-safe characters (- instead of +, _ instead of /)
51
+ * - Removes padding (=)
52
+ * @param {string} input - String to encode
53
+ * @returns {string} - Base64 URL encoded string
54
+ */
55
+ export function base64UrlEncode(input) {
56
+ // Encode to UTF-8 bytes
57
+ const bytes = new TextEncoder().encode(input);
58
+ // Convert bytes → binary string
59
+ let binary = '';
60
+ for (let i = 0; i < bytes.length; i += 1) {
61
+ binary += String.fromCharCode(bytes[i]);
62
+ }
63
+ // Standard base64
64
+ const base64 = btoa(binary);
65
+ // Convert to base64url (RFC 4648)
66
+ return base64
67
+ .replace(/\+/g, '-') // + → -
68
+ .replace(/\//g, '_') // / → _
69
+ .replace(/=+$/, ''); // remove padding
70
+ }
71
+
72
+ /**
73
+ * Generates S3 path for Tokowaka configuration based on URL
74
+ * @param {string} url - Full URL (e.g., 'https://www.example.com/products/item')
75
+ * @param {Object} logger - Logger instance
16
76
  * @param {boolean} isPreview - Whether this is a preview path
17
- * @returns {string} - S3 path
77
+ * @returns {string} - S3 path (e.g., 'opportunities/example.com/L3Byb2R1Y3RzL2l0ZW0')
78
+ * @throws {Error} - If URL parsing fails
18
79
  */
19
- export function getTokowakaConfigS3Path(apiKey, isPreview = false) {
20
- if (isPreview) {
21
- return `preview/opportunities/${apiKey}`;
80
+ export function getTokowakaConfigS3Path(url, logger, isPreview = false) {
81
+ try {
82
+ const urlObj = new URL(url);
83
+ let path = urlObj.pathname;
84
+
85
+ path = normalizePath(path);
86
+ path = base64UrlEncode(path);
87
+
88
+ const normalizedHostName = getHostName(urlObj, logger);
89
+ const prefix = isPreview ? 'preview/opportunities' : 'opportunities';
90
+
91
+ return `${prefix}/${normalizedHostName}/${path}`;
92
+ } catch (error) {
93
+ logger.error(`Error generating S3 path for URL ${url}: ${error.message}`);
94
+ throw new Error(`Failed to generate S3 path: ${error.message}`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Generates S3 path for domain-level metaconfig
100
+ * @param {string} url - Full URL (used to extract domain)
101
+ * @param {Object} logger - Logger instance
102
+ * @param {boolean} isPreview - Whether this is a preview path
103
+ * @returns {string} - S3 path for metaconfig (e.g., 'opportunities/example.com/config')
104
+ * @throws {Error} - If URL parsing fails
105
+ */
106
+ export function getTokowakaMetaconfigS3Path(url, logger, isPreview = false) {
107
+ try {
108
+ const urlObj = new URL(url);
109
+ const normalizedHostName = getHostName(urlObj, logger);
110
+ const prefix = isPreview ? 'preview/opportunities' : 'opportunities';
111
+
112
+ return `${prefix}/${normalizedHostName}/config`;
113
+ } catch (error) {
114
+ logger.error(`Error generating metaconfig S3 path for URL ${url}: ${error.message}`);
115
+ throw new Error(`Failed to generate metaconfig S3 path: ${error.message}`);
22
116
  }
23
- return `opportunities/${apiKey}`;
24
117
  }