@adobe/spacecat-shared-tokowaka-client 1.0.2 → 1.0.4
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/CHANGELOG.md +14 -0
- package/package.json +3 -3
- package/src/index.d.ts +39 -10
- package/src/index.js +41 -87
- package/src/mappers/base-mapper.js +19 -9
- package/src/mappers/content-summarization-mapper.js +38 -35
- package/src/mappers/faq-mapper.js +204 -0
- package/src/mappers/headings-mapper.js +37 -23
- package/src/mappers/mapper-registry.js +2 -0
- package/src/utils/markdown-utils.js +24 -0
- package/src/utils/patch-utils.js +66 -0
- package/src/utils/s3-utils.js +20 -0
- package/src/utils/site-utils.js +25 -0
- package/src/utils/suggestion-utils.js +69 -0
- package/test/index.test.js +113 -7
- package/test/mappers/base-mapper.test.js +107 -7
- package/test/mappers/content-mapper.test.js +26 -24
- package/test/mappers/faq-mapper.test.js +1324 -0
- package/test/mappers/faq-mapper.test.js.backup +1264 -0
- package/test/mappers/headings-mapper.test.js +25 -17
- package/test/utils/patch-utils.test.js +148 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [@adobe/spacecat-shared-tokowaka-client-v1.0.4](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.0.3...@adobe/spacecat-shared-tokowaka-client-v1.0.4) (2025-11-15)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **deps:** update external fixes ([#1131](https://github.com/adobe/spacecat-shared/issues/1131)) ([d4a3f4a](https://github.com/adobe/spacecat-shared/commit/d4a3f4a653e59e9bdde7926ea8f1a2f9b68739ff))
|
|
7
|
+
|
|
8
|
+
# [@adobe/spacecat-shared-tokowaka-client-v1.0.3](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.0.2...@adobe/spacecat-shared-tokowaka-client-v1.0.3) (2025-11-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* add faq mapper for edge deploy API ([#1083](https://github.com/adobe/spacecat-shared/issues/1083)) ([4c7ff9d](https://github.com/adobe/spacecat-shared/commit/4c7ff9db3312e76fa8965a2d53a699528975f414))
|
|
14
|
+
|
|
1
15
|
# [@adobe/spacecat-shared-tokowaka-client-v1.0.2](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.0.1...@adobe/spacecat-shared-tokowaka-client-v1.0.2) (2025-11-08)
|
|
2
16
|
|
|
3
17
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adobe/spacecat-shared-tokowaka-client",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Tokowaka Client for SpaceCat - Edge optimization config management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@adobe/spacecat-shared-utils": "1.66.1",
|
|
38
|
-
"@aws-sdk/client-cloudfront": "3.
|
|
39
|
-
"@aws-sdk/client-s3": "3.
|
|
38
|
+
"@aws-sdk/client-cloudfront": "3.932.0",
|
|
39
|
+
"@aws-sdk/client-s3": "3.932.0",
|
|
40
40
|
"mdast-util-from-markdown": "2.0.2",
|
|
41
41
|
"mdast-util-to-hast": "12.3.0"
|
|
42
42
|
},
|
package/src/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface TokawakaPatch {
|
|
|
21
21
|
currValue?: string;
|
|
22
22
|
target: 'ai-bots' | 'bots' | 'all';
|
|
23
23
|
opportunityId: string;
|
|
24
|
-
suggestionId
|
|
24
|
+
suggestionId?: string;
|
|
25
25
|
prerenderRequired: boolean;
|
|
26
26
|
lastUpdated: number;
|
|
27
27
|
}
|
|
@@ -108,12 +108,14 @@ export abstract class BaseOpportunityMapper {
|
|
|
108
108
|
abstract requiresPrerender(): boolean;
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
|
-
* Converts
|
|
111
|
+
* Converts suggestions to Tokowaka patches
|
|
112
112
|
*/
|
|
113
|
-
abstract
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
abstract suggestionsToPatches(
|
|
114
|
+
urlPath: string,
|
|
115
|
+
suggestions: Suggestion[],
|
|
116
|
+
opportunityId: string,
|
|
117
|
+
existingConfig: TokowakaConfig | null
|
|
118
|
+
): TokawakaPatch[];
|
|
117
119
|
|
|
118
120
|
/**
|
|
119
121
|
* Checks if a suggestion can be deployed for this opportunity type
|
|
@@ -142,7 +144,11 @@ export class HeadingsMapper extends BaseOpportunityMapper {
|
|
|
142
144
|
|
|
143
145
|
getOpportunityType(): string;
|
|
144
146
|
requiresPrerender(): boolean;
|
|
145
|
-
|
|
147
|
+
suggestionsToPatches(
|
|
148
|
+
urlPath: string,
|
|
149
|
+
suggestions: Suggestion[],
|
|
150
|
+
opportunityId: string
|
|
151
|
+
): TokawakaPatch[];
|
|
146
152
|
canDeploy(suggestion: Suggestion): { eligible: boolean; reason?: string };
|
|
147
153
|
}
|
|
148
154
|
|
|
@@ -155,13 +161,36 @@ export class ContentSummarizationMapper extends BaseOpportunityMapper {
|
|
|
155
161
|
|
|
156
162
|
getOpportunityType(): string;
|
|
157
163
|
requiresPrerender(): boolean;
|
|
158
|
-
|
|
164
|
+
suggestionsToPatches(
|
|
165
|
+
urlPath: string,
|
|
166
|
+
suggestions: Suggestion[],
|
|
167
|
+
opportunityId: string
|
|
168
|
+
): TokawakaPatch[];
|
|
169
|
+
canDeploy(suggestion: Suggestion): { eligible: boolean; reason?: string };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* FAQ opportunity mapper
|
|
174
|
+
* Handles conversion of FAQ suggestions to Tokowaka patches
|
|
175
|
+
*/
|
|
176
|
+
export class FaqMapper extends BaseOpportunityMapper {
|
|
177
|
+
constructor(log: any);
|
|
178
|
+
|
|
179
|
+
getOpportunityType(): string;
|
|
180
|
+
requiresPrerender(): boolean;
|
|
159
181
|
canDeploy(suggestion: Suggestion): { eligible: boolean; reason?: string };
|
|
160
182
|
|
|
161
183
|
/**
|
|
162
|
-
*
|
|
184
|
+
* Creates patches for FAQ suggestions
|
|
185
|
+
* First patch is heading (h2) if it doesn't exist, then individual FAQ divs
|
|
186
|
+
* @throws {Error} if suggestionToPatch is called directly
|
|
163
187
|
*/
|
|
164
|
-
|
|
188
|
+
suggestionsToPatches(
|
|
189
|
+
urlPath: string,
|
|
190
|
+
suggestions: Suggestion[],
|
|
191
|
+
opportunityId: string,
|
|
192
|
+
existingConfig: TokowakaConfig | null
|
|
193
|
+
): TokawakaPatch[];
|
|
165
194
|
}
|
|
166
195
|
|
|
167
196
|
/**
|
package/src/index.js
CHANGED
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
14
|
-
import { hasText, isNonEmptyObject
|
|
14
|
+
import { hasText, isNonEmptyObject } from '@adobe/spacecat-shared-utils';
|
|
15
15
|
import MapperRegistry from './mappers/mapper-registry.js';
|
|
16
16
|
import CdnClientRegistry from './cdn/cdn-client-registry.js';
|
|
17
|
+
import { mergePatches } from './utils/patch-utils.js';
|
|
18
|
+
import { getTokowakaConfigS3Path } from './utils/s3-utils.js';
|
|
19
|
+
import { groupSuggestionsByUrlPath, filterEligibleSuggestions } from './utils/suggestion-utils.js';
|
|
20
|
+
import { getEffectiveBaseURL } from './utils/site-utils.js';
|
|
17
21
|
|
|
18
22
|
const HTTP_BAD_REQUEST = 400;
|
|
19
23
|
const HTTP_INTERNAL_SERVER_ERROR = 500;
|
|
@@ -83,20 +87,14 @@ class TokowakaClient {
|
|
|
83
87
|
* Generates Tokowaka site configuration from suggestions
|
|
84
88
|
* @param {Object} site - Site entity
|
|
85
89
|
* @param {Object} opportunity - Opportunity entity
|
|
86
|
-
* @param {Array}
|
|
90
|
+
* @param {Array} suggestionsToDeploy - Array of suggestion entities to deploy
|
|
87
91
|
* @returns {Object} - Tokowaka configuration object
|
|
88
92
|
*/
|
|
89
|
-
generateConfig(site, opportunity,
|
|
93
|
+
generateConfig(site, opportunity, suggestionsToDeploy) {
|
|
90
94
|
const opportunityType = opportunity.getType();
|
|
91
95
|
const siteId = site.getId();
|
|
96
|
+
const baseURL = getEffectiveBaseURL(site);
|
|
92
97
|
|
|
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
98
|
const mapper = this.mapperRegistry.getMapper(opportunityType);
|
|
101
99
|
if (!mapper) {
|
|
102
100
|
throw this.#createError(
|
|
@@ -107,38 +105,17 @@ class TokowakaClient {
|
|
|
107
105
|
}
|
|
108
106
|
|
|
109
107
|
// Group suggestions by URL
|
|
110
|
-
const suggestionsByUrl =
|
|
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
|
-
}, {});
|
|
108
|
+
const suggestionsByUrl = groupSuggestionsByUrlPath(suggestionsToDeploy, baseURL, this.log);
|
|
133
109
|
|
|
134
110
|
// Generate patches for each URL using the mapper
|
|
135
111
|
const tokowakaOptimizations = {};
|
|
136
112
|
|
|
137
113
|
Object.entries(suggestionsByUrl).forEach(([urlPath, urlSuggestions]) => {
|
|
138
|
-
const patches =
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
114
|
+
const patches = mapper.suggestionsToPatches(
|
|
115
|
+
urlPath,
|
|
116
|
+
urlSuggestions,
|
|
117
|
+
opportunity.getId(),
|
|
118
|
+
);
|
|
142
119
|
|
|
143
120
|
if (patches.length > 0) {
|
|
144
121
|
tokowakaOptimizations[urlPath] = {
|
|
@@ -183,7 +160,7 @@ class TokowakaClient {
|
|
|
183
160
|
throw this.#createError('Tokowaka API key is required', HTTP_BAD_REQUEST);
|
|
184
161
|
}
|
|
185
162
|
|
|
186
|
-
const s3Path =
|
|
163
|
+
const s3Path = getTokowakaConfigS3Path(siteTokowakaKey);
|
|
187
164
|
|
|
188
165
|
try {
|
|
189
166
|
const command = new GetObjectCommand({
|
|
@@ -212,7 +189,9 @@ class TokowakaClient {
|
|
|
212
189
|
|
|
213
190
|
/**
|
|
214
191
|
* Merges existing configuration with new configuration
|
|
215
|
-
* For each URL path, checks
|
|
192
|
+
* For each URL path, checks patch key:
|
|
193
|
+
* - Patches are identified by opportunityId+suggestionId
|
|
194
|
+
* - Heading patches (no suggestionId) are identified by opportunityId:heading
|
|
216
195
|
* - If exists: updates the patch
|
|
217
196
|
* - If not exists: adds new patch to the array
|
|
218
197
|
* @param {Object} existingConfig - Existing configuration from S3
|
|
@@ -245,30 +224,10 @@ class TokowakaClient {
|
|
|
245
224
|
const existingPatches = existingOptimization.patches || [];
|
|
246
225
|
const newPatches = newOptimization.patches || [];
|
|
247
226
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
});
|
|
227
|
+
const { patches: mergedPatches, updateCount, addCount } = mergePatches(
|
|
228
|
+
existingPatches,
|
|
229
|
+
newPatches,
|
|
230
|
+
);
|
|
272
231
|
|
|
273
232
|
mergedConfig.tokowakaOptimizations[urlPath] = {
|
|
274
233
|
...existingOptimization,
|
|
@@ -298,7 +257,7 @@ class TokowakaClient {
|
|
|
298
257
|
throw this.#createError('Config object is required', HTTP_BAD_REQUEST);
|
|
299
258
|
}
|
|
300
259
|
|
|
301
|
-
const s3Path =
|
|
260
|
+
const s3Path = getTokowakaConfigS3Path(siteTokowakaKey);
|
|
302
261
|
|
|
303
262
|
try {
|
|
304
263
|
const command = new PutObjectCommand({
|
|
@@ -330,7 +289,7 @@ class TokowakaClient {
|
|
|
330
289
|
throw this.#createError('Tokowaka API key and provider are required', HTTP_BAD_REQUEST);
|
|
331
290
|
}
|
|
332
291
|
try {
|
|
333
|
-
const pathsToInvalidate = [
|
|
292
|
+
const pathsToInvalidate = [`/${getTokowakaConfigS3Path(apiKey)}`];
|
|
334
293
|
this.log.debug(`Invalidating CDN cache for ${pathsToInvalidate.length} paths via ${provider}`);
|
|
335
294
|
const cdnClient = this.cdnClientRegistry.getClient(provider);
|
|
336
295
|
if (!cdnClient) {
|
|
@@ -353,7 +312,7 @@ class TokowakaClient {
|
|
|
353
312
|
* Deploys suggestions to Tokowaka by generating config and uploading to S3
|
|
354
313
|
* @param {Object} site - Site entity
|
|
355
314
|
* @param {Object} opportunity - Opportunity entity
|
|
356
|
-
* @param {Array} suggestions - Array of suggestion entities
|
|
315
|
+
* @param {Array} suggestions - Array of suggestion entities to deploy
|
|
357
316
|
* @returns {Promise<Object>} - Deployment result with succeeded/failed suggestions
|
|
358
317
|
*/
|
|
359
318
|
async deploySuggestions(site, opportunity, suggestions) {
|
|
@@ -378,22 +337,15 @@ class TokowakaClient {
|
|
|
378
337
|
}
|
|
379
338
|
|
|
380
339
|
// Validate which suggestions can be deployed using mapper's canDeploy method
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
suggestions
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
|
|
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)`);
|
|
340
|
+
const {
|
|
341
|
+
eligible: eligibleSuggestions,
|
|
342
|
+
ineligible: ineligibleSuggestions,
|
|
343
|
+
} = filterEligibleSuggestions(suggestions, mapper);
|
|
344
|
+
|
|
345
|
+
this.log.debug(
|
|
346
|
+
`Deploying ${eligibleSuggestions.length} eligible suggestions `
|
|
347
|
+
+ `(${ineligibleSuggestions.length} ineligible)`,
|
|
348
|
+
);
|
|
397
349
|
|
|
398
350
|
if (eligibleSuggestions.length === 0) {
|
|
399
351
|
this.log.warn('No eligible suggestions to deploy');
|
|
@@ -409,7 +361,11 @@ class TokowakaClient {
|
|
|
409
361
|
|
|
410
362
|
// Generate configuration with eligible suggestions only
|
|
411
363
|
this.log.debug(`Generating Tokowaka config for site ${site.getId()}, opportunity ${opportunity.getId()}`);
|
|
412
|
-
const newConfig = this.generateConfig(
|
|
364
|
+
const newConfig = this.generateConfig(
|
|
365
|
+
site,
|
|
366
|
+
opportunity,
|
|
367
|
+
eligibleSuggestions,
|
|
368
|
+
);
|
|
413
369
|
|
|
414
370
|
if (Object.keys(newConfig.tokowakaOptimizations).length === 0) {
|
|
415
371
|
this.log.warn('No eligible suggestions to deploy');
|
|
@@ -419,10 +375,8 @@ class TokowakaClient {
|
|
|
419
375
|
};
|
|
420
376
|
}
|
|
421
377
|
|
|
422
|
-
// Merge with existing config
|
|
423
|
-
const config = existingConfig
|
|
424
|
-
? this.mergeConfigs(existingConfig, newConfig)
|
|
425
|
-
: newConfig;
|
|
378
|
+
// Merge with existing config
|
|
379
|
+
const config = this.mergeConfigs(existingConfig, newConfig);
|
|
426
380
|
|
|
427
381
|
// Upload to S3
|
|
428
382
|
this.log.info(`Uploading Tokowaka config for ${eligibleSuggestions.length} suggestions`);
|
|
@@ -40,16 +40,17 @@ export default class BaseOpportunityMapper {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Converts
|
|
43
|
+
* Converts suggestions to Tokowaka patches
|
|
44
44
|
* @abstract
|
|
45
|
-
* @param {
|
|
46
|
-
* @param {
|
|
47
|
-
* @
|
|
45
|
+
* @param {string} _ - URL path for the suggestions
|
|
46
|
+
* @param {Array} __ - Array of suggestion entities for the same URL
|
|
47
|
+
* @param {string} ___ - Opportunity ID
|
|
48
|
+
* @returns {Array} - Array of Tokowaka patch objects
|
|
48
49
|
*/
|
|
49
50
|
// eslint-disable-next-line no-unused-vars
|
|
50
|
-
|
|
51
|
-
this.log.error('
|
|
52
|
-
throw new Error('
|
|
51
|
+
suggestionsToPatches(_, __, ___) {
|
|
52
|
+
this.log.error('suggestionsToPatches() must be implemented by subclass');
|
|
53
|
+
throw new Error('suggestionsToPatches() must be implemented by subclass');
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
/**
|
|
@@ -73,8 +74,17 @@ export default class BaseOpportunityMapper {
|
|
|
73
74
|
* @returns {Object} - Base patch object
|
|
74
75
|
*/
|
|
75
76
|
createBasePatch(suggestion, opportunityId) {
|
|
76
|
-
const
|
|
77
|
-
const
|
|
77
|
+
const data = suggestion.getData();
|
|
78
|
+
const updatedAt = data?.scrapedAt
|
|
79
|
+
|| data?.transformRules?.scrapedAt
|
|
80
|
+
|| suggestion.getUpdatedAt();
|
|
81
|
+
|
|
82
|
+
// Parse timestamp, fallback to Date.now() if invalid
|
|
83
|
+
let lastUpdated = Date.now();
|
|
84
|
+
if (updatedAt) {
|
|
85
|
+
const parsed = new Date(updatedAt).getTime();
|
|
86
|
+
lastUpdated = Number.isNaN(parsed) ? Date.now() : parsed;
|
|
87
|
+
}
|
|
78
88
|
|
|
79
89
|
return {
|
|
80
90
|
opportunityId,
|
|
@@ -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
|
|
41
|
-
* @param {string}
|
|
42
|
-
* @
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
const mdast = fromMarkdown(markdown);
|
|
47
|
-
return toHast(mdast);
|
|
48
|
-
}
|
|
45
|
+
suggestionsToPatches(urlPath, suggestions, opportunityId) {
|
|
46
|
+
const patches = [];
|
|
49
47
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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,204 @@
|
|
|
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
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Mapper for FAQ opportunity
|
|
20
|
+
* Handles conversion of FAQ suggestions to Tokowaka patches
|
|
21
|
+
*/
|
|
22
|
+
export default class FaqMapper extends BaseOpportunityMapper {
|
|
23
|
+
constructor(log) {
|
|
24
|
+
super(log);
|
|
25
|
+
this.opportunityType = 'faq';
|
|
26
|
+
this.prerenderRequired = true;
|
|
27
|
+
this.validActions = ['insertAfter', 'insertBefore', 'appendChild'];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getOpportunityType() {
|
|
31
|
+
return this.opportunityType;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
requiresPrerender() {
|
|
35
|
+
return this.prerenderRequired;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Builds FAQ item HTML structure (div with h3 and answer)
|
|
40
|
+
* Structure: <div><h3>question</h3>answer-content</div>
|
|
41
|
+
* @param {Object} suggestion - Suggestion entity
|
|
42
|
+
* @returns {Object} - HAST object for the FAQ item
|
|
43
|
+
* @private
|
|
44
|
+
*/
|
|
45
|
+
// eslint-disable-next-line class-methods-use-this
|
|
46
|
+
buildFaqItemHast(suggestion) {
|
|
47
|
+
const data = suggestion.getData();
|
|
48
|
+
const { item } = data;
|
|
49
|
+
|
|
50
|
+
// Convert answer markdown to HAST
|
|
51
|
+
const answerHast = markdownToHast(item.answer);
|
|
52
|
+
|
|
53
|
+
// Build structure: <div><h3>question</h3>answer-hast-children</div>
|
|
54
|
+
return {
|
|
55
|
+
type: 'element',
|
|
56
|
+
tagName: 'div',
|
|
57
|
+
properties: {},
|
|
58
|
+
children: [
|
|
59
|
+
{
|
|
60
|
+
type: 'element',
|
|
61
|
+
tagName: 'h3',
|
|
62
|
+
properties: {},
|
|
63
|
+
children: [{ type: 'text', value: item.question }],
|
|
64
|
+
},
|
|
65
|
+
...answerHast.children, // Spread answer HAST children directly
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Creates individual patches for FAQ suggestions
|
|
72
|
+
* Always creates heading (h2) patch with latest timestamp, then individual FAQ divs
|
|
73
|
+
* @param {string} urlPath - URL path for current suggestions
|
|
74
|
+
* @param {Array} suggestions - Array of suggestion entities for the same URL (to be deployed)
|
|
75
|
+
* @param {string} opportunityId - Opportunity ID
|
|
76
|
+
* @returns {Array} - Array of patch objects
|
|
77
|
+
*/
|
|
78
|
+
suggestionsToPatches(
|
|
79
|
+
urlPath,
|
|
80
|
+
suggestions,
|
|
81
|
+
opportunityId,
|
|
82
|
+
) {
|
|
83
|
+
if (!urlPath || !Array.isArray(suggestions) || suggestions.length === 0) {
|
|
84
|
+
this.log.error('Invalid parameters for FAQ mapper.suggestionsToPatches');
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Filter eligible suggestions
|
|
89
|
+
const eligibleSuggestions = suggestions.filter((suggestion) => {
|
|
90
|
+
const eligibility = this.canDeploy(suggestion);
|
|
91
|
+
if (!eligibility.eligible) {
|
|
92
|
+
this.log.warn(`FAQ suggestion ${suggestion.getId()} cannot be deployed: ${eligibility.reason}`);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (eligibleSuggestions.length === 0) {
|
|
99
|
+
this.log.warn('No eligible FAQ suggestions to deploy');
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const patches = [];
|
|
104
|
+
|
|
105
|
+
// Get transformRules and headingText from first suggestion
|
|
106
|
+
const firstSuggestion = eligibleSuggestions[0];
|
|
107
|
+
const firstData = firstSuggestion.getData();
|
|
108
|
+
const { headingText = 'FAQs', transformRules } = firstData;
|
|
109
|
+
|
|
110
|
+
// Calculate the most recent lastUpdated from all eligible suggestions
|
|
111
|
+
// The heading patch should have the same timestamp as the newest FAQ
|
|
112
|
+
const maxLastUpdated = Math.max(...eligibleSuggestions.map((suggestion) => {
|
|
113
|
+
const data = suggestion.getData();
|
|
114
|
+
const updatedAt = data?.scrapedAt
|
|
115
|
+
|| data?.transformRules?.scrapedAt
|
|
116
|
+
|| suggestion.getUpdatedAt();
|
|
117
|
+
|
|
118
|
+
if (updatedAt) {
|
|
119
|
+
const parsed = new Date(updatedAt).getTime();
|
|
120
|
+
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
121
|
+
}
|
|
122
|
+
return Date.now();
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
// Always create/update heading patch with latest timestamp
|
|
126
|
+
// mergePatches will replace existing heading if it already exists
|
|
127
|
+
this.log.debug(`Creating/updating heading patch for ${urlPath}`);
|
|
128
|
+
|
|
129
|
+
const headingHast = {
|
|
130
|
+
type: 'element',
|
|
131
|
+
tagName: 'h2',
|
|
132
|
+
properties: {},
|
|
133
|
+
children: [{ type: 'text', value: headingText }],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
patches.push({
|
|
137
|
+
opportunityId,
|
|
138
|
+
// No suggestionId for FAQ heading patch
|
|
139
|
+
prerenderRequired: this.requiresPrerender(),
|
|
140
|
+
lastUpdated: maxLastUpdated,
|
|
141
|
+
op: transformRules.action,
|
|
142
|
+
selector: transformRules.selector,
|
|
143
|
+
value: headingHast,
|
|
144
|
+
valueFormat: 'hast',
|
|
145
|
+
target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Create individual FAQ patches
|
|
149
|
+
eligibleSuggestions.forEach((suggestion) => {
|
|
150
|
+
try {
|
|
151
|
+
const faqItemHast = this.buildFaqItemHast(suggestion);
|
|
152
|
+
|
|
153
|
+
patches.push({
|
|
154
|
+
...this.createBasePatch(suggestion, opportunityId),
|
|
155
|
+
op: transformRules.action,
|
|
156
|
+
selector: transformRules.selector,
|
|
157
|
+
value: faqItemHast,
|
|
158
|
+
valueFormat: 'hast',
|
|
159
|
+
target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
|
|
160
|
+
});
|
|
161
|
+
} catch (error) {
|
|
162
|
+
this.log.error(`Failed to build FAQ HAST for suggestion ${suggestion.getId()}: ${error.message}`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return patches;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Checks if a FAQ suggestion can be deployed
|
|
171
|
+
* @param {Object} suggestion - Suggestion object
|
|
172
|
+
* @returns {Object} { eligible: boolean, reason?: string }
|
|
173
|
+
*/
|
|
174
|
+
canDeploy(suggestion) {
|
|
175
|
+
const data = suggestion.getData();
|
|
176
|
+
|
|
177
|
+
// Check shouldOptimize flag first
|
|
178
|
+
if (data?.shouldOptimize !== true) {
|
|
179
|
+
return { eligible: false, reason: 'shouldOptimize flag is not true' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!data?.item?.question || !data?.item?.answer) {
|
|
183
|
+
return { eligible: false, reason: 'item.question and item.answer are required' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!data.transformRules) {
|
|
187
|
+
return { eligible: false, reason: 'transformRules is required' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!hasText(data.transformRules.selector)) {
|
|
191
|
+
return { eligible: false, reason: 'transformRules.selector is required' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!this.validActions.includes(data.transformRules.action)) {
|
|
195
|
+
return { eligible: false, reason: 'transformRules.action must be insertAfter, insertBefore, or appendChild' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!isValidUrl(data.url)) {
|
|
199
|
+
return { eligible: false, reason: `url ${data.url} is not a valid URL` };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { eligible: true };
|
|
203
|
+
}
|
|
204
|
+
}
|