@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.
- package/.releaserc.cjs +17 -0
- package/CHANGELOG.md +69 -1
- package/CODE_OF_CONDUCT.md +75 -0
- package/CONTRIBUTING.md +74 -0
- package/README.md +155 -15
- package/package.json +4 -4
- package/src/index.d.ts +120 -25
- package/src/index.js +481 -177
- package/src/mappers/base-mapper.js +41 -9
- package/src/mappers/content-summarization-mapper.js +38 -35
- package/src/mappers/faq-mapper.js +247 -0
- package/src/mappers/headings-mapper.js +37 -23
- package/src/mappers/mapper-registry.js +2 -0
- package/src/utils/custom-html-utils.js +195 -0
- package/src/utils/markdown-utils.js +24 -0
- package/src/utils/patch-utils.js +103 -0
- package/src/utils/s3-utils.js +117 -0
- package/src/utils/site-utils.js +25 -0
- package/src/utils/suggestion-utils.js +69 -0
- package/test/index.test.js +1268 -462
- package/test/mappers/base-mapper.test.js +250 -7
- package/test/mappers/content-mapper.test.js +26 -24
- package/test/mappers/faq-mapper.test.js +1428 -0
- package/test/mappers/headings-mapper.test.js +23 -17
- package/test/utils/html-utils.test.js +432 -0
- package/test/utils/patch-utils.test.js +409 -0
- package/test/utils/s3-utils.test.js +140 -0
- package/test/utils/site-utils.test.js +80 -0
- package/test/utils/suggestion-utils.test.js +187 -0
|
@@ -0,0 +1,195 @@
|
|
|
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 } from '@adobe/spacecat-shared-utils';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Helper function to wait for a specified duration
|
|
17
|
+
* @param {number} ms - Milliseconds to wait
|
|
18
|
+
* @returns {Promise<void>}
|
|
19
|
+
*/
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
setTimeout(resolve, ms);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Makes an HTTP request with retry logic
|
|
28
|
+
* Retries until max retries are exhausted or x-tokowaka-cache header is present
|
|
29
|
+
* @param {string} url - URL to fetch
|
|
30
|
+
* @param {Object} options - Fetch options
|
|
31
|
+
* @param {number} maxRetries - Maximum number of retries
|
|
32
|
+
* @param {number} retryDelayMs - Delay between retries in milliseconds
|
|
33
|
+
* @param {Object} log - Logger instance
|
|
34
|
+
* @param {string} fetchType - Context for logging (e.g., "optimized" or "original")
|
|
35
|
+
* @returns {Promise<Response>} - Fetch response
|
|
36
|
+
*/
|
|
37
|
+
async function fetchWithRetry(url, options, maxRetries, retryDelayMs, log, fetchType) {
|
|
38
|
+
for (let attempt = 1; attempt <= maxRetries + 1; attempt += 1) {
|
|
39
|
+
try {
|
|
40
|
+
log.debug(`Retry attempt ${attempt}/${maxRetries} for ${fetchType} HTML`);
|
|
41
|
+
|
|
42
|
+
// eslint-disable-next-line no-await-in-loop
|
|
43
|
+
const response = await fetch(url, options);
|
|
44
|
+
|
|
45
|
+
log.debug(`Response status (attempt ${attempt}): ${response.status} ${response.statusText}`);
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check for x-tokowaka-cache header - if present, stop retrying
|
|
52
|
+
const cacheHeader = response.headers.get('x-tokowaka-cache');
|
|
53
|
+
if (cacheHeader) {
|
|
54
|
+
log.debug(`Cache header found (x-tokowaka-cache: ${cacheHeader}), stopping retry logic`);
|
|
55
|
+
return response;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// If no cache header and we haven't exhausted retries, continue
|
|
59
|
+
if (attempt < maxRetries + 1) {
|
|
60
|
+
log.debug(`No cache header found on attempt ${attempt}, will retry...`);
|
|
61
|
+
// Wait before retrying
|
|
62
|
+
log.debug(`Waiting ${retryDelayMs}ms before retry...`);
|
|
63
|
+
// eslint-disable-next-line no-await-in-loop
|
|
64
|
+
await sleep(retryDelayMs);
|
|
65
|
+
} else {
|
|
66
|
+
// Last attempt without cache header - throw error
|
|
67
|
+
log.error(`Max retries (${maxRetries}) exhausted without cache header`);
|
|
68
|
+
throw new Error(`Cache header (x-tokowaka-cache) not found after ${maxRetries} retries`);
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
log.warn(`Attempt ${attempt} failed for ${fetchType} HTML, error: ${error.message}`);
|
|
72
|
+
|
|
73
|
+
// If this was the last attempt, throw the error
|
|
74
|
+
if (attempt === maxRetries + 1) {
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Wait before retrying
|
|
79
|
+
log.debug(`Waiting ${retryDelayMs}ms before retry...`);
|
|
80
|
+
// eslint-disable-next-line no-await-in-loop
|
|
81
|
+
await sleep(retryDelayMs);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/* c8 ignore next */
|
|
85
|
+
throw new Error(`Failed to fetch ${fetchType} HTML after ${maxRetries} retries`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fetches HTML content from Tokowaka edge with warmup call and retry logic
|
|
90
|
+
* Makes an initial warmup call, waits, then makes the actual call with retries
|
|
91
|
+
* @param {string} url - Full URL to fetch
|
|
92
|
+
* @param {string} apiKey - Tokowaka API key
|
|
93
|
+
* @param {string} forwardedHost - Host to forward in x-forwarded-host header
|
|
94
|
+
* @param {string} tokowakaEdgeUrl - Tokowaka edge URL
|
|
95
|
+
* @param {boolean} isOptimized - Whether to fetch optimized HTML (with preview param)
|
|
96
|
+
* @param {Object} log - Logger instance
|
|
97
|
+
* @param {Object} options - Additional options
|
|
98
|
+
* @param {number} options.warmupDelayMs - Delay after warmup call (default: 2000ms)
|
|
99
|
+
* @param {number} options.maxRetries - Maximum number of retries for actual call (default: 2)
|
|
100
|
+
* @param {number} options.retryDelayMs - Delay between retries (default: 1000ms)
|
|
101
|
+
* @returns {Promise<string>} - HTML content
|
|
102
|
+
* @throws {Error} - If validation fails or fetch fails after retries
|
|
103
|
+
*/
|
|
104
|
+
export async function fetchHtmlWithWarmup(
|
|
105
|
+
url,
|
|
106
|
+
apiKey,
|
|
107
|
+
forwardedHost,
|
|
108
|
+
tokowakaEdgeUrl,
|
|
109
|
+
log,
|
|
110
|
+
isOptimized = false,
|
|
111
|
+
options = {},
|
|
112
|
+
) {
|
|
113
|
+
// Validate required parameters
|
|
114
|
+
if (!hasText(url)) {
|
|
115
|
+
throw new Error('URL is required for fetching HTML');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!hasText(apiKey)) {
|
|
119
|
+
throw new Error('Tokowaka API key is required for fetching HTML');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!hasText(forwardedHost)) {
|
|
123
|
+
throw new Error('Forwarded host is required for fetching HTML');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!hasText(tokowakaEdgeUrl)) {
|
|
127
|
+
throw new Error('TOKOWAKA_EDGE_URL is not configured');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Default options
|
|
131
|
+
const {
|
|
132
|
+
warmupDelayMs = 2000,
|
|
133
|
+
maxRetries = 3,
|
|
134
|
+
retryDelayMs = 1000,
|
|
135
|
+
} = options;
|
|
136
|
+
|
|
137
|
+
const fetchType = isOptimized ? 'optimized' : 'original';
|
|
138
|
+
|
|
139
|
+
// Parse the URL to extract path and construct full URL
|
|
140
|
+
const urlObj = new URL(url);
|
|
141
|
+
const urlPath = urlObj.pathname + urlObj.search;
|
|
142
|
+
|
|
143
|
+
// Add tokowakaPreview param for optimized HTML
|
|
144
|
+
let fullUrl = `${tokowakaEdgeUrl}${urlPath}`;
|
|
145
|
+
if (isOptimized) {
|
|
146
|
+
const separator = urlPath.includes('?') ? '&' : '?';
|
|
147
|
+
fullUrl = `${fullUrl}${separator}tokowakaPreview=true`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const headers = {
|
|
151
|
+
'x-forwarded-host': forwardedHost,
|
|
152
|
+
'x-tokowaka-api-key': apiKey,
|
|
153
|
+
'x-tokowaka-url': urlPath,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const fetchOptions = {
|
|
157
|
+
method: 'GET',
|
|
158
|
+
headers,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
// Warmup call (no retry logic for warmup)
|
|
163
|
+
log.debug(`Making warmup call for ${fetchType} HTML with URL: ${fullUrl}`);
|
|
164
|
+
|
|
165
|
+
const warmupResponse = await fetch(fullUrl, fetchOptions);
|
|
166
|
+
|
|
167
|
+
log.debug(`Warmup response status: ${warmupResponse.status} ${warmupResponse.statusText}`);
|
|
168
|
+
// Consume the response body to free up the connection
|
|
169
|
+
await warmupResponse.text();
|
|
170
|
+
log.debug(`Warmup call completed, waiting ${warmupDelayMs}ms...`);
|
|
171
|
+
|
|
172
|
+
// Wait before actual call
|
|
173
|
+
await sleep(warmupDelayMs);
|
|
174
|
+
|
|
175
|
+
// Actual call with retry logic
|
|
176
|
+
log.debug(`Making actual call for ${fetchType} HTML (max ${maxRetries} retries) with URL: ${fullUrl}`);
|
|
177
|
+
|
|
178
|
+
const response = await fetchWithRetry(
|
|
179
|
+
fullUrl,
|
|
180
|
+
fetchOptions,
|
|
181
|
+
maxRetries,
|
|
182
|
+
retryDelayMs,
|
|
183
|
+
log,
|
|
184
|
+
fetchType,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const html = await response.text();
|
|
188
|
+
log.debug(`Successfully fetched ${fetchType} HTML (${html.length} bytes)`);
|
|
189
|
+
return html;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const errorMsg = `Failed to fetch ${fetchType} HTML after ${maxRetries} retries: ${error.message}`;
|
|
192
|
+
log.error(errorMsg);
|
|
193
|
+
throw new Error(errorMsg);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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 { toHast } from 'mdast-util-to-hast';
|
|
14
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Converts markdown text to HAST (Hypertext Abstract Syntax Tree) format
|
|
18
|
+
* @param {string} markdown - Markdown text
|
|
19
|
+
* @returns {Object} - HAST object
|
|
20
|
+
*/
|
|
21
|
+
export function markdownToHast(markdown) {
|
|
22
|
+
const mdast = fromMarkdown(markdown);
|
|
23
|
+
return toHast(mdast);
|
|
24
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
/**
|
|
14
|
+
* Generates a unique key for a patch based on its structure
|
|
15
|
+
* Individual patches (one suggestion per patch):
|
|
16
|
+
* → Key: opportunityId:suggestionId
|
|
17
|
+
* Patches with no suggestionId:
|
|
18
|
+
* → Key: opportunityId
|
|
19
|
+
*/
|
|
20
|
+
export function getPatchKey(patch) {
|
|
21
|
+
// Heading patch (no suggestionId): use special key
|
|
22
|
+
if (!patch.suggestionId) {
|
|
23
|
+
return `${patch.opportunityId}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Individual patches include suggestionId in key
|
|
27
|
+
// This ensures each suggestion gets its own separate patch
|
|
28
|
+
return `${patch.opportunityId}:${patch.suggestionId}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Merges new patches into existing patches based on patch keys
|
|
33
|
+
* - If a patch with the same key exists, it's updated
|
|
34
|
+
* - If a patch with a new key is found, it's added
|
|
35
|
+
* @param {Array} existingPatches - Array of existing patches
|
|
36
|
+
* @param {Array} newPatches - Array of new patches to merge
|
|
37
|
+
* @returns {Object} - { patches: Array, updateCount: number, addCount: number }
|
|
38
|
+
*/
|
|
39
|
+
export function mergePatches(existingPatches, newPatches) {
|
|
40
|
+
// Create a map of existing patches by their key
|
|
41
|
+
const patchMap = new Map();
|
|
42
|
+
existingPatches.forEach((patch, index) => {
|
|
43
|
+
const key = getPatchKey(patch);
|
|
44
|
+
patchMap.set(key, { patch, index });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Process new patches
|
|
48
|
+
const mergedPatches = [...existingPatches];
|
|
49
|
+
let updateCount = 0;
|
|
50
|
+
let addCount = 0;
|
|
51
|
+
|
|
52
|
+
newPatches.forEach((newPatch) => {
|
|
53
|
+
const key = getPatchKey(newPatch);
|
|
54
|
+
const existing = patchMap.get(key);
|
|
55
|
+
|
|
56
|
+
if (existing) {
|
|
57
|
+
mergedPatches[existing.index] = newPatch;
|
|
58
|
+
updateCount += 1;
|
|
59
|
+
} else {
|
|
60
|
+
mergedPatches.push(newPatch);
|
|
61
|
+
addCount += 1;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return { patches: mergedPatches, updateCount, addCount };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Removes patches matching the given suggestion IDs from a config
|
|
70
|
+
* Works with flat config structure
|
|
71
|
+
* @param {Object} config - Tokowaka configuration object
|
|
72
|
+
* @param {Array<string>} suggestionIds - Array of suggestion IDs to remove
|
|
73
|
+
* @param {Array<string>} additionalPatchKeys - Optional array of additional patch keys to remove
|
|
74
|
+
* @returns {Object} - Updated configuration with patches removed
|
|
75
|
+
*/
|
|
76
|
+
export function removePatchesBySuggestionIds(config, suggestionIds, additionalPatchKeys = []) {
|
|
77
|
+
if (!config || !config.patches) {
|
|
78
|
+
return config;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const suggestionIdSet = new Set(suggestionIds);
|
|
82
|
+
const patchKeysToRemove = new Set(additionalPatchKeys);
|
|
83
|
+
let removedCount = 0;
|
|
84
|
+
|
|
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);
|
|
90
|
+
|
|
91
|
+
if (shouldRemoveBySuggestionId || shouldRemoveByPatchKey) {
|
|
92
|
+
removedCount += 1;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
...config,
|
|
100
|
+
patches: filteredPatches,
|
|
101
|
+
removedCount,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
/**
|
|
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
|
|
76
|
+
* @param {boolean} isPreview - Whether this is a preview path
|
|
77
|
+
* @returns {string} - S3 path (e.g., 'opportunities/example.com/L3Byb2R1Y3RzL2l0ZW0')
|
|
78
|
+
* @throws {Error} - If URL parsing fails
|
|
79
|
+
*/
|
|
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}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
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 { isValidUrl } from '@adobe/spacecat-shared-utils';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Gets the effective base URL for a site, respecting overrideBaseURL if configured
|
|
17
|
+
* @param {Object} site - Site entity
|
|
18
|
+
* @returns {string} - Base URL to use
|
|
19
|
+
*/
|
|
20
|
+
export function getEffectiveBaseURL(site) {
|
|
21
|
+
const overrideBaseURL = site.getConfig()?.getFetchConfig?.()?.overrideBaseURL;
|
|
22
|
+
return (overrideBaseURL && isValidUrl(overrideBaseURL))
|
|
23
|
+
? overrideBaseURL
|
|
24
|
+
: site.getBaseURL();
|
|
25
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
/**
|
|
14
|
+
* Groups suggestions by URL pathname
|
|
15
|
+
* @param {Array} suggestions - Array of suggestion entities
|
|
16
|
+
* @param {string} baseURL - Base URL for pathname extraction
|
|
17
|
+
* @param {Object} log - Logger instance
|
|
18
|
+
* @returns {Object} - Object with URL paths as keys and arrays of suggestions as values
|
|
19
|
+
*/
|
|
20
|
+
export function groupSuggestionsByUrlPath(suggestions, baseURL, log) {
|
|
21
|
+
return suggestions.reduce((acc, suggestion) => {
|
|
22
|
+
const data = suggestion.getData();
|
|
23
|
+
const url = data?.url;
|
|
24
|
+
|
|
25
|
+
if (!url) {
|
|
26
|
+
log.warn(`Suggestion ${suggestion.getId()} does not have a URL, skipping`);
|
|
27
|
+
return acc;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let urlPath;
|
|
31
|
+
try {
|
|
32
|
+
urlPath = new URL(url, baseURL).pathname;
|
|
33
|
+
} catch (e) {
|
|
34
|
+
log.warn(`Failed to extract pathname from URL for suggestion ${suggestion.getId()}: ${url}`);
|
|
35
|
+
return acc;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!acc[urlPath]) {
|
|
39
|
+
acc[urlPath] = [];
|
|
40
|
+
}
|
|
41
|
+
acc[urlPath].push(suggestion);
|
|
42
|
+
return acc;
|
|
43
|
+
}, {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Filters suggestions into eligible and ineligible based on mapper's canDeploy method
|
|
48
|
+
* @param {Array} suggestions - Array of suggestion entities
|
|
49
|
+
* @param {Object} mapper - Mapper instance with canDeploy method
|
|
50
|
+
* @returns {Object} - { eligible: Array, ineligible: Array<{suggestion, reason}> }
|
|
51
|
+
*/
|
|
52
|
+
export function filterEligibleSuggestions(suggestions, mapper) {
|
|
53
|
+
const eligible = [];
|
|
54
|
+
const ineligible = [];
|
|
55
|
+
|
|
56
|
+
suggestions.forEach((suggestion) => {
|
|
57
|
+
const eligibility = mapper.canDeploy(suggestion);
|
|
58
|
+
if (eligibility.eligible) {
|
|
59
|
+
eligible.push(suggestion);
|
|
60
|
+
} else {
|
|
61
|
+
ineligible.push({
|
|
62
|
+
suggestion,
|
|
63
|
+
reason: eligibility.reason || 'Suggestion cannot be deployed',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return { eligible, ineligible };
|
|
69
|
+
}
|