@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 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.2",
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.927.0",
39
- "@aws-sdk/client-s3": "3.927.0",
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: string;
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 a suggestion to a Tokowaka patch
111
+ * Converts suggestions to Tokowaka patches
112
112
  */
113
- abstract suggestionToPatch(
114
- suggestion: Suggestion,
115
- opportunityId: string
116
- ): TokawakaPatch | null;
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
- suggestionToPatch(suggestion: Suggestion, opportunityId: string): TokawakaPatch | null;
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
- suggestionToPatch(suggestion: Suggestion, opportunityId: string): TokawakaPatch | null;
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
- * Converts markdown text to HAST (Hypertext Abstract Syntax Tree) format
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
- markdownToHast(markdown: string): object;
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, isValidUrl } from '@adobe/spacecat-shared-utils';
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} suggestions - Array of suggestion entities
90
+ * @param {Array} suggestionsToDeploy - Array of suggestion entities to deploy
87
91
  * @returns {Object} - Tokowaka configuration object
88
92
  */
89
- generateConfig(site, opportunity, suggestions) {
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 = 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
- }, {});
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 = urlSuggestions.map((suggestion) => {
139
- const patch = mapper.suggestionToPatch(suggestion, opportunity.getId());
140
- return patch;
141
- }).filter((patch) => patch !== null);
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 = `opportunities/${siteTokowakaKey}`;
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 if opportunityId+suggestionId combination exists:
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
- // 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
- });
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 = `opportunities/${siteTokowakaKey}`;
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 = [`/opportunities/${apiKey}`];
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 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)`);
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(site, opportunity, eligibleSuggestions);
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 if it exists
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 a suggestion to a Tokowaka patch
43
+ * Converts suggestions to Tokowaka patches
44
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
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
- suggestionToPatch(_, __) {
51
- this.log.error('suggestionToPatch() must be implemented by subclass');
52
- throw new Error('suggestionToPatch() must be implemented by subclass');
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 updatedAt = suggestion.getUpdatedAt();
77
- const lastUpdated = updatedAt ? new Date(updatedAt).getTime() : Date.now();
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 markdown text to HAST (Hypertext Abstract Syntax Tree) format
41
- * @param {string} markdown - Markdown text
42
- * @returns {Object} - HAST object
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
- // eslint-disable-next-line class-methods-use-this
45
- markdownToHast(markdown) {
46
- const mdast = fromMarkdown(markdown);
47
- return toHast(mdast);
48
- }
45
+ suggestionsToPatches(urlPath, suggestions, opportunityId) {
46
+ const patches = [];
49
47
 
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
- }
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
- 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
- }
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
+ }