@adobe/spacecat-shared-tokowaka-client 1.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.
- package/.mocha-multi.json +7 -0
- package/.nycrc.json +15 -0
- package/.releaserc.cjs +17 -0
- package/CHANGELOG.md +24 -0
- package/CODE_OF_CONDUCT.md +75 -0
- package/CONTRIBUTING.md +74 -0
- package/LICENSE.txt +203 -0
- package/README.md +101 -0
- package/package.json +53 -0
- package/src/cdn/base-cdn-client.js +50 -0
- package/src/cdn/cdn-client-registry.js +87 -0
- package/src/cdn/cloudfront-cdn-client.js +128 -0
- package/src/constants.js +17 -0
- package/src/index.d.ts +289 -0
- package/src/index.js +447 -0
- package/src/mappers/base-mapper.js +86 -0
- package/src/mappers/content-summarization-mapper.js +106 -0
- package/src/mappers/headings-mapper.js +118 -0
- package/src/mappers/mapper-registry.js +87 -0
- package/test/cdn/base-cdn-client.test.js +52 -0
- package/test/cdn/cdn-client-registry.test.js +179 -0
- package/test/cdn/cloudfront-cdn-client.test.js +330 -0
- package/test/index.test.js +1142 -0
- package/test/mappers/base-mapper.test.js +110 -0
- package/test/mappers/content-mapper.test.js +355 -0
- package/test/mappers/headings-mapper.test.js +428 -0
- package/test/mappers/mapper-registry.test.js +197 -0
- package/test/setup-env.js +18 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
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 { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
14
|
+
import { hasText, isNonEmptyObject, isValidUrl } from '@adobe/spacecat-shared-utils';
|
|
15
|
+
import MapperRegistry from './mappers/mapper-registry.js';
|
|
16
|
+
import CdnClientRegistry from './cdn/cdn-client-registry.js';
|
|
17
|
+
|
|
18
|
+
const HTTP_BAD_REQUEST = 400;
|
|
19
|
+
const HTTP_INTERNAL_SERVER_ERROR = 500;
|
|
20
|
+
const HTTP_NOT_IMPLEMENTED = 501;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Tokowaka Client - Manages edge optimization configurations
|
|
24
|
+
*/
|
|
25
|
+
class TokowakaClient {
|
|
26
|
+
/**
|
|
27
|
+
* Creates a TokowakaClient from context
|
|
28
|
+
* @param {Object} context - The context object
|
|
29
|
+
* @returns {TokowakaClient} - The client instance
|
|
30
|
+
*/
|
|
31
|
+
static createFrom(context) {
|
|
32
|
+
const { env, log = console, s3 } = context;
|
|
33
|
+
const { TOKOWAKA_SITE_CONFIG_BUCKET: bucketName } = env;
|
|
34
|
+
|
|
35
|
+
if (context.tokowakaClient) {
|
|
36
|
+
return context.tokowakaClient;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// s3ClientWrapper puts s3Client at context.s3.s3Client, so check both locations
|
|
40
|
+
const client = new TokowakaClient({
|
|
41
|
+
bucketName,
|
|
42
|
+
s3Client: s3?.s3Client,
|
|
43
|
+
env,
|
|
44
|
+
}, log);
|
|
45
|
+
context.tokowakaClient = client;
|
|
46
|
+
return client;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Constructor
|
|
51
|
+
* @param {Object} config - Configuration object
|
|
52
|
+
* @param {string} config.bucketName - S3 bucket name for configs
|
|
53
|
+
* @param {Object} config.s3Client - AWS S3 client
|
|
54
|
+
* @param {Object} config.env - Environment variables (for CDN credentials)
|
|
55
|
+
* @param {Object} log - Logger instance
|
|
56
|
+
*/
|
|
57
|
+
constructor({ bucketName, s3Client, env = {} }, log) {
|
|
58
|
+
this.log = log;
|
|
59
|
+
|
|
60
|
+
if (!hasText(bucketName)) {
|
|
61
|
+
throw this.#createError('TOKOWAKA_SITE_CONFIG_BUCKET is required', HTTP_BAD_REQUEST);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!isNonEmptyObject(s3Client)) {
|
|
65
|
+
throw this.#createError('S3 client is required', HTTP_BAD_REQUEST);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.bucketName = bucketName;
|
|
69
|
+
this.s3Client = s3Client;
|
|
70
|
+
this.env = env;
|
|
71
|
+
|
|
72
|
+
this.mapperRegistry = new MapperRegistry(log);
|
|
73
|
+
this.cdnClientRegistry = new CdnClientRegistry(env, log);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#createError(message, status) {
|
|
77
|
+
const error = Object.assign(new Error(message), { status });
|
|
78
|
+
this.log.error(error.message);
|
|
79
|
+
return error;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generates Tokowaka site configuration from suggestions
|
|
84
|
+
* @param {Object} site - Site entity
|
|
85
|
+
* @param {Object} opportunity - Opportunity entity
|
|
86
|
+
* @param {Array} suggestions - Array of suggestion entities
|
|
87
|
+
* @returns {Object} - Tokowaka configuration object
|
|
88
|
+
*/
|
|
89
|
+
generateConfig(site, opportunity, suggestions) {
|
|
90
|
+
const opportunityType = opportunity.getType();
|
|
91
|
+
const siteId = site.getId();
|
|
92
|
+
|
|
93
|
+
// Get baseURL, respecting overrideBaseURL from fetchConfig if it exists
|
|
94
|
+
const overrideBaseURL = site.getConfig()?.getFetchConfig?.()?.overrideBaseURL;
|
|
95
|
+
const baseURL = (overrideBaseURL && isValidUrl(overrideBaseURL))
|
|
96
|
+
? overrideBaseURL
|
|
97
|
+
: site.getBaseURL();
|
|
98
|
+
|
|
99
|
+
// Get mapper for this opportunity type
|
|
100
|
+
const mapper = this.mapperRegistry.getMapper(opportunityType);
|
|
101
|
+
if (!mapper) {
|
|
102
|
+
throw this.#createError(
|
|
103
|
+
`No mapper found for opportunity type: ${opportunityType}. `
|
|
104
|
+
+ `Supported types: ${this.mapperRegistry.getSupportedOpportunityTypes().join(', ')}`,
|
|
105
|
+
HTTP_NOT_IMPLEMENTED,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Group suggestions by URL
|
|
110
|
+
const suggestionsByUrl = suggestions.reduce((acc, suggestion) => {
|
|
111
|
+
const data = suggestion.getData();
|
|
112
|
+
const url = data?.url;
|
|
113
|
+
|
|
114
|
+
if (!url) {
|
|
115
|
+
this.log.warn(`Suggestion ${suggestion.getId()} does not have a URL, skipping`);
|
|
116
|
+
return acc;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let urlPath;
|
|
120
|
+
try {
|
|
121
|
+
urlPath = new URL(url, baseURL).pathname;
|
|
122
|
+
} catch (e) {
|
|
123
|
+
this.log.warn(`Failed to extract pathname from URL for suggestion ${suggestion.getId()}: ${url}`);
|
|
124
|
+
return acc;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!acc[urlPath]) {
|
|
128
|
+
acc[urlPath] = [];
|
|
129
|
+
}
|
|
130
|
+
acc[urlPath].push(suggestion);
|
|
131
|
+
return acc;
|
|
132
|
+
}, {});
|
|
133
|
+
|
|
134
|
+
// Generate patches for each URL using the mapper
|
|
135
|
+
const tokowakaOptimizations = {};
|
|
136
|
+
|
|
137
|
+
Object.entries(suggestionsByUrl).forEach(([urlPath, urlSuggestions]) => {
|
|
138
|
+
const patches = urlSuggestions.map((suggestion) => {
|
|
139
|
+
const patch = mapper.suggestionToPatch(suggestion, opportunity.getId());
|
|
140
|
+
return patch;
|
|
141
|
+
}).filter((patch) => patch !== null);
|
|
142
|
+
|
|
143
|
+
if (patches.length > 0) {
|
|
144
|
+
tokowakaOptimizations[urlPath] = {
|
|
145
|
+
prerender: mapper.requiresPrerender(),
|
|
146
|
+
patches,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
siteId,
|
|
153
|
+
baseURL,
|
|
154
|
+
version: '1.0',
|
|
155
|
+
tokowakaForceFail: false,
|
|
156
|
+
tokowakaOptimizations,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Gets list of supported opportunity types
|
|
162
|
+
* @returns {string[]} - Array of supported opportunity types
|
|
163
|
+
*/
|
|
164
|
+
getSupportedOpportunityTypes() {
|
|
165
|
+
return this.mapperRegistry.getSupportedOpportunityTypes();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Registers a custom mapper for an opportunity type
|
|
170
|
+
* @param {BaseOpportunityMapper} mapper - Mapper instance
|
|
171
|
+
*/
|
|
172
|
+
registerMapper(mapper) {
|
|
173
|
+
this.mapperRegistry.registerMapper(mapper);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Fetches existing Tokowaka configuration from S3
|
|
178
|
+
* @param {string} siteTokowakaKey - Tokowaka API key (used as S3 key prefix)
|
|
179
|
+
* @returns {Promise<Object|null>} - Existing configuration object or null if not found
|
|
180
|
+
*/
|
|
181
|
+
async fetchConfig(siteTokowakaKey) {
|
|
182
|
+
if (!hasText(siteTokowakaKey)) {
|
|
183
|
+
throw this.#createError('Tokowaka API key is required', HTTP_BAD_REQUEST);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const s3Path = `opportunities/${siteTokowakaKey}`;
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const command = new GetObjectCommand({
|
|
190
|
+
Bucket: this.bucketName,
|
|
191
|
+
Key: s3Path,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const response = await this.s3Client.send(command);
|
|
195
|
+
const bodyContents = await response.Body.transformToString();
|
|
196
|
+
const config = JSON.parse(bodyContents);
|
|
197
|
+
|
|
198
|
+
this.log.debug(`Successfully fetched existing Tokowaka config from s3://${this.bucketName}/${s3Path}`);
|
|
199
|
+
return config;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
// If config doesn't exist (NoSuchKey), return null
|
|
202
|
+
if (error.name === 'NoSuchKey' || error.Code === 'NoSuchKey') {
|
|
203
|
+
this.log.debug(`No existing Tokowaka config found at s3://${this.bucketName}/${s3Path}`);
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// For other errors, log and throw
|
|
208
|
+
this.log.error(`Failed to fetch Tokowaka config from S3: ${error.message}`, error);
|
|
209
|
+
throw this.#createError(`S3 fetch failed: ${error.message}`, HTTP_INTERNAL_SERVER_ERROR);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Merges existing configuration with new configuration
|
|
215
|
+
* For each URL path, checks if opportunityId+suggestionId combination exists:
|
|
216
|
+
* - If exists: updates the patch
|
|
217
|
+
* - If not exists: adds new patch to the array
|
|
218
|
+
* @param {Object} existingConfig - Existing configuration from S3
|
|
219
|
+
* @param {Object} newConfig - New configuration generated from suggestions
|
|
220
|
+
* @returns {Object} - Merged configuration
|
|
221
|
+
*/
|
|
222
|
+
mergeConfigs(existingConfig, newConfig) {
|
|
223
|
+
if (!existingConfig) {
|
|
224
|
+
return newConfig;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Start with existing config structure
|
|
228
|
+
const mergedConfig = {
|
|
229
|
+
...existingConfig,
|
|
230
|
+
baseURL: newConfig.baseURL,
|
|
231
|
+
version: newConfig.version,
|
|
232
|
+
tokowakaForceFail: newConfig.tokowakaForceFail,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Merge optimizations for each URL path
|
|
236
|
+
Object.entries(newConfig.tokowakaOptimizations).forEach(([urlPath, newOptimization]) => {
|
|
237
|
+
const existingOptimization = mergedConfig.tokowakaOptimizations[urlPath];
|
|
238
|
+
|
|
239
|
+
if (!existingOptimization) {
|
|
240
|
+
// URL path doesn't exist in existing config, add it entirely
|
|
241
|
+
mergedConfig.tokowakaOptimizations[urlPath] = newOptimization;
|
|
242
|
+
this.log.debug(`Added new URL path: ${urlPath}`);
|
|
243
|
+
} else {
|
|
244
|
+
// URL path exists, merge patches
|
|
245
|
+
const existingPatches = existingOptimization.patches || [];
|
|
246
|
+
const newPatches = newOptimization.patches || [];
|
|
247
|
+
|
|
248
|
+
// Create a map of existing patches by opportunityId+suggestionId
|
|
249
|
+
const patchMap = new Map();
|
|
250
|
+
existingPatches.forEach((patch, index) => {
|
|
251
|
+
const key = `${patch.opportunityId}:${patch.suggestionId}`;
|
|
252
|
+
patchMap.set(key, { patch, index });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Process new patches
|
|
256
|
+
const mergedPatches = [...existingPatches];
|
|
257
|
+
let updateCount = 0;
|
|
258
|
+
let addCount = 0;
|
|
259
|
+
|
|
260
|
+
newPatches.forEach((newPatch) => {
|
|
261
|
+
const key = `${newPatch.opportunityId}:${newPatch.suggestionId}`;
|
|
262
|
+
const existing = patchMap.get(key);
|
|
263
|
+
|
|
264
|
+
if (existing) {
|
|
265
|
+
mergedPatches[existing.index] = newPatch;
|
|
266
|
+
updateCount += 1;
|
|
267
|
+
} else {
|
|
268
|
+
mergedPatches.push(newPatch);
|
|
269
|
+
addCount += 1;
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
mergedConfig.tokowakaOptimizations[urlPath] = {
|
|
274
|
+
...existingOptimization,
|
|
275
|
+
prerender: newOptimization.prerender,
|
|
276
|
+
patches: mergedPatches,
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
this.log.debug(`Merged patches for ${urlPath}: ${updateCount} updated, ${addCount} added`);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
return mergedConfig;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Uploads Tokowaka configuration to S3
|
|
288
|
+
* @param {string} siteTokowakaKey - Tokowaka API key (used as S3 key prefix)
|
|
289
|
+
* @param {Object} config - Tokowaka configuration object
|
|
290
|
+
* @returns {Promise<string>} - S3 key of uploaded config
|
|
291
|
+
*/
|
|
292
|
+
async uploadConfig(siteTokowakaKey, config) {
|
|
293
|
+
if (!hasText(siteTokowakaKey)) {
|
|
294
|
+
throw this.#createError('Tokowaka API key is required', HTTP_BAD_REQUEST);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!isNonEmptyObject(config)) {
|
|
298
|
+
throw this.#createError('Config object is required', HTTP_BAD_REQUEST);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const s3Path = `opportunities/${siteTokowakaKey}`;
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const command = new PutObjectCommand({
|
|
305
|
+
Bucket: this.bucketName,
|
|
306
|
+
Key: s3Path,
|
|
307
|
+
Body: JSON.stringify(config, null, 2),
|
|
308
|
+
ContentType: 'application/json',
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
await this.s3Client.send(command);
|
|
312
|
+
this.log.info(`Successfully uploaded Tokowaka config to s3://${this.bucketName}/${s3Path}`);
|
|
313
|
+
|
|
314
|
+
return s3Path;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
this.log.error(`Failed to upload Tokowaka config to S3: ${error.message}`, error);
|
|
317
|
+
throw this.#createError(`S3 upload failed: ${error.message}`, HTTP_INTERNAL_SERVER_ERROR);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Invalidates CDN cache for the Tokowaka config
|
|
323
|
+
* Currently supports CloudFront only
|
|
324
|
+
* @param {string} apiKey - Tokowaka API key
|
|
325
|
+
* @param {string} provider - CDN provider name (default: 'cloudfront')
|
|
326
|
+
* @returns {Promise<Object|null>} - CDN invalidation result or null if skipped
|
|
327
|
+
*/
|
|
328
|
+
async invalidateCdnCache(apiKey, provider) {
|
|
329
|
+
if (!hasText(apiKey) || !hasText(provider)) {
|
|
330
|
+
throw this.#createError('Tokowaka API key and provider are required', HTTP_BAD_REQUEST);
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const pathsToInvalidate = [`/opportunities/${apiKey}`];
|
|
334
|
+
this.log.debug(`Invalidating CDN cache for ${pathsToInvalidate.length} paths via ${provider}`);
|
|
335
|
+
const cdnClient = this.cdnClientRegistry.getClient(provider);
|
|
336
|
+
if (!cdnClient) {
|
|
337
|
+
throw this.#createError(`No CDN client available for provider: ${provider}`, HTTP_NOT_IMPLEMENTED);
|
|
338
|
+
}
|
|
339
|
+
const result = await cdnClient.invalidateCache(pathsToInvalidate);
|
|
340
|
+
this.log.info(`CDN cache invalidation completed: ${JSON.stringify(result)}`);
|
|
341
|
+
return result;
|
|
342
|
+
} catch (error) {
|
|
343
|
+
this.log.error(`Failed to invalidate Tokowaka CDN cache: ${error.message}`, error);
|
|
344
|
+
return {
|
|
345
|
+
status: 'error',
|
|
346
|
+
provider: 'cloudfront',
|
|
347
|
+
message: error.message,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Deploys suggestions to Tokowaka by generating config and uploading to S3
|
|
354
|
+
* @param {Object} site - Site entity
|
|
355
|
+
* @param {Object} opportunity - Opportunity entity
|
|
356
|
+
* @param {Array} suggestions - Array of suggestion entities
|
|
357
|
+
* @returns {Promise<Object>} - Deployment result with succeeded/failed suggestions
|
|
358
|
+
*/
|
|
359
|
+
async deploySuggestions(site, opportunity, suggestions) {
|
|
360
|
+
// Get site's Tokowaka API key
|
|
361
|
+
const { apiKey } = site.getConfig()?.getTokowakaConfig() || {};
|
|
362
|
+
|
|
363
|
+
if (!hasText(apiKey)) {
|
|
364
|
+
throw this.#createError(
|
|
365
|
+
'Site does not have a Tokowaka API key configured. Please onboard the site to Tokowaka first.',
|
|
366
|
+
HTTP_BAD_REQUEST,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const opportunityType = opportunity.getType();
|
|
371
|
+
const mapper = this.mapperRegistry.getMapper(opportunityType);
|
|
372
|
+
if (!mapper) {
|
|
373
|
+
throw this.#createError(
|
|
374
|
+
`No mapper found for opportunity type: ${opportunityType}. `
|
|
375
|
+
+ `Supported types: ${this.mapperRegistry.getSupportedOpportunityTypes().join(', ')}`,
|
|
376
|
+
HTTP_NOT_IMPLEMENTED,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Validate which suggestions can be deployed using mapper's canDeploy method
|
|
381
|
+
const eligibleSuggestions = [];
|
|
382
|
+
const ineligibleSuggestions = [];
|
|
383
|
+
|
|
384
|
+
suggestions.forEach((suggestion) => {
|
|
385
|
+
const eligibility = mapper.canDeploy(suggestion);
|
|
386
|
+
if (eligibility.eligible) {
|
|
387
|
+
eligibleSuggestions.push(suggestion);
|
|
388
|
+
} else {
|
|
389
|
+
ineligibleSuggestions.push({
|
|
390
|
+
suggestion,
|
|
391
|
+
reason: eligibility.reason || 'Suggestion cannot be deployed',
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
this.log.debug(`Deploying ${eligibleSuggestions.length} eligible suggestions (${ineligibleSuggestions.length} ineligible)`);
|
|
397
|
+
|
|
398
|
+
if (eligibleSuggestions.length === 0) {
|
|
399
|
+
this.log.warn('No eligible suggestions to deploy');
|
|
400
|
+
return {
|
|
401
|
+
succeededSuggestions: [],
|
|
402
|
+
failedSuggestions: ineligibleSuggestions,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Fetch existing configuration from S3
|
|
407
|
+
this.log.debug(`Fetching existing Tokowaka config for site ${site.getId()}`);
|
|
408
|
+
const existingConfig = await this.fetchConfig(apiKey);
|
|
409
|
+
|
|
410
|
+
// Generate configuration with eligible suggestions only
|
|
411
|
+
this.log.debug(`Generating Tokowaka config for site ${site.getId()}, opportunity ${opportunity.getId()}`);
|
|
412
|
+
const newConfig = this.generateConfig(site, opportunity, eligibleSuggestions);
|
|
413
|
+
|
|
414
|
+
if (Object.keys(newConfig.tokowakaOptimizations).length === 0) {
|
|
415
|
+
this.log.warn('No eligible suggestions to deploy');
|
|
416
|
+
return {
|
|
417
|
+
succeededSuggestions: [],
|
|
418
|
+
failedSuggestions: suggestions,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Merge with existing config if it exists
|
|
423
|
+
const config = existingConfig
|
|
424
|
+
? this.mergeConfigs(existingConfig, newConfig)
|
|
425
|
+
: newConfig;
|
|
426
|
+
|
|
427
|
+
// Upload to S3
|
|
428
|
+
this.log.info(`Uploading Tokowaka config for ${eligibleSuggestions.length} suggestions`);
|
|
429
|
+
const s3Path = await this.uploadConfig(apiKey, config);
|
|
430
|
+
|
|
431
|
+
// Invalidate CDN cache (non-blocking, failures are logged but don't fail deployment)
|
|
432
|
+
const cdnInvalidationResult = await this.invalidateCdnCache(
|
|
433
|
+
apiKey,
|
|
434
|
+
this.env.TOKOWAKA_CDN_PROVIDER,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
s3Path,
|
|
439
|
+
cdnInvalidation: cdnInvalidationResult,
|
|
440
|
+
succeededSuggestions: eligibleSuggestions,
|
|
441
|
+
failedSuggestions: ineligibleSuggestions,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Export the client as default and base classes for custom implementations
|
|
447
|
+
export default TokowakaClient;
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
* Base class for opportunity mappers
|
|
15
|
+
* Each opportunity type should extend this class and implement the abstract methods
|
|
16
|
+
*/
|
|
17
|
+
export default class BaseOpportunityMapper {
|
|
18
|
+
constructor(log) {
|
|
19
|
+
this.log = log;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the opportunity type this mapper handles
|
|
24
|
+
* @abstract
|
|
25
|
+
* @returns {string} - Opportunity type
|
|
26
|
+
*/
|
|
27
|
+
getOpportunityType() {
|
|
28
|
+
this.log.error('getOpportunityType() must be implemented by subclass');
|
|
29
|
+
throw new Error('getOpportunityType() must be implemented by subclass');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Determines if prerendering is required for this opportunity type
|
|
34
|
+
* @abstract
|
|
35
|
+
* @returns {boolean} - True if prerendering is required
|
|
36
|
+
*/
|
|
37
|
+
requiresPrerender() {
|
|
38
|
+
this.log.error('requiresPrerender() must be implemented by subclass');
|
|
39
|
+
throw new Error('requiresPrerender() must be implemented by subclass');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Converts a suggestion to a Tokowaka patch
|
|
44
|
+
* @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
|
|
48
|
+
*/
|
|
49
|
+
// 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
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Checks if a suggestion can be deployed for this opportunity type
|
|
57
|
+
* This method should validate all eligibility and data requirements
|
|
58
|
+
* @abstract
|
|
59
|
+
* @param {Object} _ - Suggestion object
|
|
60
|
+
* @returns {Object} - { eligible: boolean, reason?: string }
|
|
61
|
+
*/
|
|
62
|
+
// eslint-disable-next-line no-unused-vars
|
|
63
|
+
canDeploy(_) {
|
|
64
|
+
this.log.error('canDeploy() must be implemented by subclass');
|
|
65
|
+
throw new Error('canDeploy() must be implemented by subclass');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Helper method to create base patch structure
|
|
70
|
+
* @protected
|
|
71
|
+
* @param {Object} suggestion - Suggestion entity with getUpdatedAt() method
|
|
72
|
+
* @param {string} opportunityId - Opportunity ID
|
|
73
|
+
* @returns {Object} - Base patch object
|
|
74
|
+
*/
|
|
75
|
+
createBasePatch(suggestion, opportunityId) {
|
|
76
|
+
const updatedAt = suggestion.getUpdatedAt();
|
|
77
|
+
const lastUpdated = updatedAt ? new Date(updatedAt).getTime() : Date.now();
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
opportunityId,
|
|
81
|
+
suggestionId: suggestion.getId(),
|
|
82
|
+
prerenderRequired: this.requiresPrerender(),
|
|
83
|
+
lastUpdated,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
import { hasText } from '@adobe/spacecat-shared-utils';
|
|
16
|
+
import { TARGET_USER_AGENTS_CATEGORIES } from '../constants.js';
|
|
17
|
+
import BaseOpportunityMapper from './base-mapper.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Mapper for content opportunity
|
|
21
|
+
* Handles conversion of content summarization suggestions to Tokowaka patches
|
|
22
|
+
*/
|
|
23
|
+
export default class ContentSummarizationMapper extends BaseOpportunityMapper {
|
|
24
|
+
constructor(log) {
|
|
25
|
+
super(log);
|
|
26
|
+
this.opportunityType = 'summarization';
|
|
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
|
+
* Converts markdown text to HAST (Hypertext Abstract Syntax Tree) format
|
|
41
|
+
* @param {string} markdown - Markdown text
|
|
42
|
+
* @returns {Object} - HAST object
|
|
43
|
+
*/
|
|
44
|
+
// eslint-disable-next-line class-methods-use-this
|
|
45
|
+
markdownToHast(markdown) {
|
|
46
|
+
const mdast = fromMarkdown(markdown);
|
|
47
|
+
return toHast(mdast);
|
|
48
|
+
}
|
|
49
|
+
|
|
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
|
+
}
|
|
56
|
+
|
|
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
|
+
}
|
|
68
|
+
|
|
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
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Checks if a content suggestion can be deployed
|
|
81
|
+
* @param {Object} suggestion - Suggestion object
|
|
82
|
+
* @returns {Object} { eligible: boolean, reason?: string }
|
|
83
|
+
*/
|
|
84
|
+
canDeploy(suggestion) {
|
|
85
|
+
const data = suggestion.getData();
|
|
86
|
+
|
|
87
|
+
// Validate required fields
|
|
88
|
+
if (!data?.summarizationText) {
|
|
89
|
+
return { eligible: false, reason: 'summarizationText is required' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!data.transformRules) {
|
|
93
|
+
return { eligible: false, reason: 'transformRules is required' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!hasText(data.transformRules.selector)) {
|
|
97
|
+
return { eligible: false, reason: 'transformRules.selector is required' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!this.validActions.includes(data.transformRules.action)) {
|
|
101
|
+
return { eligible: false, reason: 'transformRules.action must be insertAfter, insertBefore, or appendChild' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { eligible: true };
|
|
105
|
+
}
|
|
106
|
+
}
|