@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/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
+ }