@get-technology-inc/jamf-docs-mcp-server 3.0.6 → 3.0.8

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.
Files changed (190) hide show
  1. package/README.md +37 -2
  2. package/dist/core/constants/doc-types.d.ts +48 -0
  3. package/dist/core/constants/doc-types.d.ts.map +1 -0
  4. package/dist/core/constants/doc-types.js +58 -0
  5. package/dist/core/constants/doc-types.js.map +1 -0
  6. package/dist/core/constants/index.d.ts +16 -0
  7. package/dist/core/constants/index.d.ts.map +1 -0
  8. package/dist/core/constants/index.js +12 -0
  9. package/dist/core/constants/index.js.map +1 -0
  10. package/dist/core/constants/limits.d.ts +39 -0
  11. package/dist/core/constants/limits.d.ts.map +1 -0
  12. package/dist/core/constants/limits.js +51 -0
  13. package/dist/core/constants/limits.js.map +1 -0
  14. package/dist/core/constants/locales.d.ts +34 -0
  15. package/dist/core/constants/locales.d.ts.map +1 -0
  16. package/dist/core/constants/locales.js +20 -0
  17. package/dist/core/constants/locales.js.map +1 -0
  18. package/dist/core/constants/products.d.ts +127 -0
  19. package/dist/core/constants/products.d.ts.map +1 -0
  20. package/dist/core/constants/products.js +135 -0
  21. package/dist/core/constants/products.js.map +1 -0
  22. package/dist/core/constants/topics.d.ts +168 -0
  23. package/dist/core/constants/topics.d.ts.map +1 -0
  24. package/dist/core/constants/topics.js +186 -0
  25. package/dist/core/constants/topics.js.map +1 -0
  26. package/dist/core/constants.d.ts +7 -404
  27. package/dist/core/constants.d.ts.map +1 -1
  28. package/dist/core/constants.js +16 -427
  29. package/dist/core/constants.js.map +1 -1
  30. package/dist/core/http-client.d.ts +14 -2
  31. package/dist/core/http-client.d.ts.map +1 -1
  32. package/dist/core/http-client.js +148 -17
  33. package/dist/core/http-client.js.map +1 -1
  34. package/dist/core/index.d.ts +12 -12
  35. package/dist/core/index.d.ts.map +1 -1
  36. package/dist/core/index.js +12 -13
  37. package/dist/core/index.js.map +1 -1
  38. package/dist/core/resources/index.js +1 -1
  39. package/dist/core/resources/index.js.map +1 -1
  40. package/dist/core/schemas/index.d.ts +3 -1
  41. package/dist/core/schemas/index.d.ts.map +1 -1
  42. package/dist/core/schemas/index.js +13 -3
  43. package/dist/core/schemas/index.js.map +1 -1
  44. package/dist/core/schemas/output.d.ts +7 -0
  45. package/dist/core/schemas/output.d.ts.map +1 -1
  46. package/dist/core/schemas/output.js +7 -0
  47. package/dist/core/schemas/output.js.map +1 -1
  48. package/dist/core/services/article-service.d.ts +24 -0
  49. package/dist/core/services/article-service.d.ts.map +1 -0
  50. package/dist/core/services/article-service.js +140 -0
  51. package/dist/core/services/article-service.js.map +1 -0
  52. package/dist/core/services/content-parser.d.ts +38 -0
  53. package/dist/core/services/content-parser.d.ts.map +1 -0
  54. package/dist/core/services/content-parser.js +148 -0
  55. package/dist/core/services/content-parser.js.map +1 -0
  56. package/dist/core/services/ft-client.d.ts +52 -0
  57. package/dist/core/services/ft-client.d.ts.map +1 -0
  58. package/dist/core/services/ft-client.js +87 -0
  59. package/dist/core/services/ft-client.js.map +1 -0
  60. package/dist/core/services/glossary.d.ts +9 -5
  61. package/dist/core/services/glossary.d.ts.map +1 -1
  62. package/dist/core/services/glossary.js +175 -108
  63. package/dist/core/services/glossary.js.map +1 -1
  64. package/dist/core/services/interfaces/cache.d.ts +6 -6
  65. package/dist/core/services/interfaces/cache.d.ts.map +1 -1
  66. package/dist/core/services/interfaces/index.d.ts +3 -3
  67. package/dist/core/services/interfaces/index.d.ts.map +1 -1
  68. package/dist/core/services/interfaces/logger.d.ts +17 -10
  69. package/dist/core/services/interfaces/logger.d.ts.map +1 -1
  70. package/dist/core/services/interfaces/metadata.d.ts +0 -10
  71. package/dist/core/services/interfaces/metadata.d.ts.map +1 -1
  72. package/dist/core/services/interfaces/providers.d.ts +23 -10
  73. package/dist/core/services/interfaces/providers.d.ts.map +1 -1
  74. package/dist/core/services/logging.d.ts +38 -18
  75. package/dist/core/services/logging.d.ts.map +1 -1
  76. package/dist/core/services/logging.js +78 -29
  77. package/dist/core/services/logging.js.map +1 -1
  78. package/dist/core/services/maps-registry.d.ts +82 -0
  79. package/dist/core/services/maps-registry.d.ts.map +1 -0
  80. package/dist/core/services/maps-registry.js +232 -0
  81. package/dist/core/services/maps-registry.js.map +1 -0
  82. package/dist/core/services/metadata.d.ts +18 -9
  83. package/dist/core/services/metadata.d.ts.map +1 -1
  84. package/dist/core/services/metadata.js +113 -254
  85. package/dist/core/services/metadata.js.map +1 -1
  86. package/dist/core/services/search-service.d.ts +31 -0
  87. package/dist/core/services/search-service.d.ts.map +1 -0
  88. package/dist/core/services/search-service.js +462 -0
  89. package/dist/core/services/search-service.js.map +1 -0
  90. package/dist/core/services/toc-service.d.ts +30 -0
  91. package/dist/core/services/toc-service.d.ts.map +1 -0
  92. package/dist/core/services/toc-service.js +107 -0
  93. package/dist/core/services/toc-service.js.map +1 -0
  94. package/dist/core/services/tokenizer.d.ts +20 -1
  95. package/dist/core/services/tokenizer.d.ts.map +1 -1
  96. package/dist/core/services/tokenizer.js +32 -4
  97. package/dist/core/services/tokenizer.js.map +1 -1
  98. package/dist/core/services/topic-resolver.d.ts +74 -0
  99. package/dist/core/services/topic-resolver.d.ts.map +1 -0
  100. package/dist/core/services/topic-resolver.js +191 -0
  101. package/dist/core/services/topic-resolver.js.map +1 -0
  102. package/dist/core/tools/batch-get-articles.d.ts +0 -2
  103. package/dist/core/tools/batch-get-articles.d.ts.map +1 -1
  104. package/dist/core/tools/batch-get-articles.js +18 -46
  105. package/dist/core/tools/batch-get-articles.js.map +1 -1
  106. package/dist/core/tools/get-article.d.ts.map +1 -1
  107. package/dist/core/tools/get-article.js +29 -112
  108. package/dist/core/tools/get-article.js.map +1 -1
  109. package/dist/core/tools/get-toc.js +1 -1
  110. package/dist/core/tools/get-toc.js.map +1 -1
  111. package/dist/core/tools/glossary-lookup.d.ts.map +1 -1
  112. package/dist/core/tools/glossary-lookup.js +26 -7
  113. package/dist/core/tools/glossary-lookup.js.map +1 -1
  114. package/dist/core/tools/list-products.d.ts.map +1 -1
  115. package/dist/core/tools/list-products.js +5 -3
  116. package/dist/core/tools/list-products.js.map +1 -1
  117. package/dist/core/tools/search.js +5 -5
  118. package/dist/core/tools/search.js.map +1 -1
  119. package/dist/core/types/context.d.ts +5 -2
  120. package/dist/core/types/context.d.ts.map +1 -1
  121. package/dist/core/types.d.ts +141 -31
  122. package/dist/core/types.d.ts.map +1 -1
  123. package/dist/core/types.js.map +1 -1
  124. package/dist/core/utils/bundle.d.ts +14 -1
  125. package/dist/core/utils/bundle.d.ts.map +1 -1
  126. package/dist/core/utils/bundle.js +19 -2
  127. package/dist/core/utils/bundle.js.map +1 -1
  128. package/dist/core/utils/format-article.d.ts +49 -0
  129. package/dist/core/utils/format-article.d.ts.map +1 -0
  130. package/dist/core/utils/format-article.js +188 -0
  131. package/dist/core/utils/format-article.js.map +1 -0
  132. package/dist/core/utils/ft-metadata.d.ts +22 -0
  133. package/dist/core/utils/ft-metadata.d.ts.map +1 -0
  134. package/dist/core/utils/ft-metadata.js +30 -0
  135. package/dist/core/utils/ft-metadata.js.map +1 -0
  136. package/dist/core/utils/sanitize.d.ts.map +1 -1
  137. package/dist/core/utils/sanitize.js +0 -2
  138. package/dist/core/utils/sanitize.js.map +1 -1
  139. package/dist/core/utils/url.d.ts.map +1 -1
  140. package/dist/core/utils/url.js +0 -1
  141. package/dist/core/utils/url.js.map +1 -1
  142. package/dist/index.js +10 -6
  143. package/dist/index.js.map +1 -1
  144. package/dist/platforms/node/http-server.d.ts +13 -0
  145. package/dist/platforms/node/http-server.d.ts.map +1 -0
  146. package/dist/platforms/node/http-server.js +231 -0
  147. package/dist/platforms/node/http-server.js.map +1 -0
  148. package/dist/platforms/node/index.d.ts +1 -1
  149. package/dist/platforms/node/index.d.ts.map +1 -1
  150. package/dist/platforms/node/index.js +1 -1
  151. package/dist/platforms/node/index.js.map +1 -1
  152. package/dist/platforms/node/logger.d.ts +13 -2
  153. package/dist/platforms/node/logger.d.ts.map +1 -1
  154. package/dist/platforms/node/logger.js +17 -4
  155. package/dist/platforms/node/logger.js.map +1 -1
  156. package/dist/transport/http-handler.d.ts +25 -0
  157. package/dist/transport/http-handler.d.ts.map +1 -0
  158. package/dist/transport/http-handler.js +193 -0
  159. package/dist/transport/http-handler.js.map +1 -0
  160. package/dist/transport/http-types.d.ts +22 -0
  161. package/dist/transport/http-types.d.ts.map +1 -0
  162. package/dist/transport/http-types.js +65 -0
  163. package/dist/transport/http-types.js.map +1 -0
  164. package/dist/transport/http.d.ts +4 -8
  165. package/dist/transport/http.d.ts.map +1 -1
  166. package/dist/transport/http.js +4 -376
  167. package/dist/transport/http.js.map +1 -1
  168. package/dist/transport/index.js +2 -2
  169. package/dist/transport/index.js.map +1 -1
  170. package/dist/transport/rate-limiter.d.ts +16 -0
  171. package/dist/transport/rate-limiter.d.ts.map +1 -0
  172. package/dist/transport/rate-limiter.js +55 -0
  173. package/dist/transport/rate-limiter.js.map +1 -0
  174. package/package.json +3 -3
  175. package/dist/core/services/interfaces.d.ts +0 -94
  176. package/dist/core/services/interfaces.d.ts.map +0 -1
  177. package/dist/core/services/interfaces.js +0 -8
  178. package/dist/core/services/interfaces.js.map +0 -1
  179. package/dist/core/services/scraper.d.ts +0 -90
  180. package/dist/core/services/scraper.d.ts.map +0 -1
  181. package/dist/core/services/scraper.js +0 -990
  182. package/dist/core/services/scraper.js.map +0 -1
  183. package/dist/core/utils/doc-type.d.ts +0 -12
  184. package/dist/core/utils/doc-type.d.ts.map +0 -1
  185. package/dist/core/utils/doc-type.js +0 -24
  186. package/dist/core/utils/doc-type.js.map +0 -1
  187. package/dist/platforms/node/metadata.d.ts +0 -18
  188. package/dist/platforms/node/metadata.d.ts.map +0 -1
  189. package/dist/platforms/node/metadata.js +0 -35
  190. package/dist/platforms/node/metadata.js.map +0 -1
@@ -1,990 +0,0 @@
1
- /**
2
- * Web scraping service for Jamf documentation
3
- *
4
- * This module handles fetching and parsing HTML from learn.jamf.com
5
- * (Jamf documentation has moved from docs.jamf.com to learn.jamf.com)
6
- */
7
- import { httpGetText, httpGetJson, HttpError } from '../http-client.js';
8
- import * as cheerio from 'cheerio';
9
- import { sanitizeErrorMessage } from '../utils/sanitize.js';
10
- import { isAllowedHostname, extractLocaleFromUrl } from '../utils/url.js';
11
- import TurndownService from 'turndown';
12
- import { DOCS_BASE_URL, DOCS_API_URL, JAMF_PRODUCTS, JAMF_TOPICS, DOC_TYPE_LABEL_MAP, SELECTORS, CONTENT_LIMITS, TOKEN_CONFIG, PAGINATION_CONFIG, DEFAULT_LOCALE, SUPPORTED_LOCALES } from '../constants.js';
13
- import { JamfDocsError, JamfDocsErrorCode } from '../types.js';
14
- import { extractVersionFromBundleId, extractProductSlug, compareVersions } from '../utils/bundle.js';
15
- import { docTypeFromLabels } from '../utils/doc-type.js';
16
- import { estimateTokens, createTokenInfo, extractSections, extractSection, extractSummary, truncateToTokenLimit, calculatePagination } from './tokenizer.js';
17
- import { getBundleIdForVersion } from './metadata.js';
18
- // Initialize Turndown for HTML to Markdown conversion
19
- const turndown = new TurndownService({
20
- headingStyle: 'atx',
21
- codeBlockStyle: 'fenced',
22
- bulletListMarker: '-',
23
- emDelimiter: '*',
24
- strongDelimiter: '**'
25
- });
26
- // Custom Turndown rules
27
- turndown.addRule('codeBlocks', {
28
- filter: 'pre',
29
- replacement: (content, node) => {
30
- // Handle code element extraction safely
31
- const nodeElement = node;
32
- const codeElement = nodeElement.querySelector?.('code');
33
- const language = codeElement?.className?.replace('language-', '') ?? '';
34
- return `\n\`\`\`${language}\n${content.trim()}\n\`\`\`\n`;
35
- }
36
- });
37
- // Rate limiter
38
- let lastRequestTime = 0;
39
- async function throttle(requestConfig) {
40
- const now = Date.now();
41
- const elapsed = now - lastRequestTime;
42
- if (elapsed < requestConfig.rateLimitDelay) {
43
- await new Promise(resolve => setTimeout(resolve, requestConfig.rateLimitDelay - elapsed));
44
- }
45
- lastRequestTime = Date.now();
46
- }
47
- // Re-export URL utilities for backward compatibility
48
- export { ALLOWED_HOSTNAMES, isAllowedHostname } from '../utils/url.js';
49
- /**
50
- * Strip locale prefix (e.g. /en-US/, /ja-JP/) from URL path.
51
- * Frontend URLs include locale in path but backend doesn't accept it.
52
- */
53
- export function stripLocalePrefix(urlStr) {
54
- try {
55
- const url = new URL(urlStr);
56
- const segments = url.pathname.split('/').filter(Boolean);
57
- const firstSegment = segments[0];
58
- if (firstSegment !== undefined && firstSegment in SUPPORTED_LOCALES) {
59
- url.pathname = `/${segments.slice(1).join('/')}`;
60
- return url.toString();
61
- }
62
- }
63
- catch {
64
- // Invalid URL, return as-is
65
- }
66
- return urlStr;
67
- }
68
- // URL transformation between frontend (learn.jamf.com) and backend (learn-be.jamf.com)
69
- export function transformToBackendUrl(urlStr) {
70
- const url = new URL(urlStr);
71
- if (url.hostname === 'learn.jamf.com') {
72
- url.hostname = 'learn-be.jamf.com';
73
- }
74
- return url.toString();
75
- }
76
- export function transformToFrontendUrl(urlStr) {
77
- const url = new URL(urlStr);
78
- if (url.hostname === 'learn-be.jamf.com') {
79
- url.hostname = 'learn.jamf.com';
80
- }
81
- return url.toString();
82
- }
83
- // bundleId validation pattern
84
- const BUNDLE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
85
- function validateBundleId(bundleId) {
86
- if (!BUNDLE_ID_PATTERN.test(bundleId)) {
87
- throw new JamfDocsError(`Invalid bundle ID: ${bundleId}`, JamfDocsErrorCode.PARSE_ERROR);
88
- }
89
- return bundleId;
90
- }
91
- // HTML entity map for stripping HTML
92
- const HTML_ENTITIES = {
93
- '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'"
94
- };
95
- /**
96
- * Strip HTML tags from a string
97
- * Uses iterative approach to handle nested/malformed tags
98
- */
99
- function stripHtml(html) {
100
- // Truncate input before processing to prevent CPU exhaustion on large inputs
101
- let text = html.length > CONTENT_LIMITS.MAX_SNIPPET_LENGTH * 2
102
- ? html.slice(0, CONTENT_LIMITS.MAX_SNIPPET_LENGTH * 2)
103
- : html;
104
- // Iteratively remove HTML tags until none remain (handles nested cases like <scr<script>ipt>)
105
- const MAX_ITERATIONS = 10;
106
- let prev = '';
107
- let iterations = 0;
108
- while (prev !== text && iterations < MAX_ITERATIONS) {
109
- prev = text;
110
- text = text.replace(/<[^>]*>/g, '');
111
- iterations++;
112
- }
113
- for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
114
- text = text.replaceAll(entity, char);
115
- }
116
- return text.replace(/\s+/g, ' ').trim();
117
- }
118
- // Minimum meaningful snippet length
119
- const MIN_SNIPPET_LENGTH = 50;
120
- // Breadcrumb/navigation patterns to filter
121
- const NAV_PATTERNS = [
122
- /^Home\s*>/i,
123
- /^[\w\s]+>\s*[\w\s]+>\s*[\w\s]+/, // "A > B > C" breadcrumb pattern
124
- ];
125
- /**
126
- * Clean and validate a search result snippet
127
- * Filters navigation-only text and ensures minimum content quality
128
- */
129
- export function cleanSnippet(snippet, title, product) {
130
- let cleaned = snippet;
131
- // Strip navigation/breadcrumb prefixes
132
- for (const pattern of NAV_PATTERNS) {
133
- cleaned = cleaned.replace(pattern, '').trim();
134
- }
135
- // If snippet is too short after cleaning, use fallback
136
- if (cleaned.length < MIN_SNIPPET_LENGTH) {
137
- const productSuffix = product !== null && product !== '' ? ` \u2014 ${product}` : '';
138
- return `${title}${productSuffix}`;
139
- }
140
- return cleaned;
141
- }
142
- /**
143
- * Handle HTTP errors and convert to JamfDocsError
144
- */
145
- function handleHttpError(error, url, resourceType) {
146
- const status = error.status;
147
- if (status === 404) {
148
- throw new JamfDocsError(`${resourceType} not found: ${url}`, JamfDocsErrorCode.NOT_FOUND, url, 404);
149
- }
150
- if (status === 429) {
151
- throw new JamfDocsError('Rate limited. Please wait and try again.', JamfDocsErrorCode.RATE_LIMITED, url, 429);
152
- }
153
- throw new JamfDocsError(`Network error: ${sanitizeErrorMessage(error.message)}`, JamfDocsErrorCode.NETWORK_ERROR, url);
154
- }
155
- /**
156
- * Build Accept-Language header value for a given locale
157
- */
158
- export function buildAcceptLanguage(locale) {
159
- if (locale === 'en-US') {
160
- return 'en-US,en;q=0.9';
161
- }
162
- return `${locale},en;q=0.5`;
163
- }
164
- /**
165
- * Fetch JSON from a URL with error handling
166
- */
167
- function makeFetchJson(requestConfig) {
168
- return async (url, locale) => {
169
- await throttle(requestConfig);
170
- try {
171
- return await httpGetJson(url, {
172
- timeout: requestConfig.timeout,
173
- headers: {
174
- 'User-Agent': requestConfig.userAgent,
175
- 'Accept-Language': buildAcceptLanguage(locale ?? DEFAULT_LOCALE)
176
- }
177
- });
178
- }
179
- catch (error) {
180
- if (error instanceof HttpError) {
181
- handleHttpError(error, url, 'Resource');
182
- }
183
- throw error;
184
- }
185
- };
186
- }
187
- /**
188
- * Fetch HTML from a URL with error handling
189
- */
190
- function makeFetchHtml(requestConfig) {
191
- return async (url, locale) => {
192
- await throttle(requestConfig);
193
- try {
194
- return await httpGetText(url, {
195
- timeout: requestConfig.timeout,
196
- headers: {
197
- 'User-Agent': requestConfig.userAgent,
198
- 'Accept': 'text/html,application/xhtml+xml',
199
- 'Accept-Language': buildAcceptLanguage(locale ?? DEFAULT_LOCALE)
200
- }
201
- });
202
- }
203
- catch (error) {
204
- if (error instanceof HttpError) {
205
- handleHttpError(error, url, 'Article');
206
- }
207
- throw error;
208
- }
209
- };
210
- }
211
- /**
212
- * Fetch HTML from a URL with error handling.
213
- * Requires a ServerContext for request configuration.
214
- */
215
- export async function fetchHtml(ctx, url, locale) {
216
- return makeFetchHtml(ctx.config.request)(url, locale);
217
- }
218
- /**
219
- * Clean HTML content by removing unwanted elements
220
- */
221
- function cleanHtml($) {
222
- // Remove unwanted elements
223
- $(SELECTORS.REMOVE).remove();
224
- // Fix relative URLs
225
- $('a[href^="/"]').each((_, el) => {
226
- const href = $(el).attr('href');
227
- if (href !== undefined && href !== '') {
228
- $(el).attr('href', `${DOCS_BASE_URL}${href}`);
229
- }
230
- });
231
- $('img[src^="/"]').each((_, el) => {
232
- const src = $(el).attr('src');
233
- if (src !== undefined && src !== '') {
234
- $(el).attr('src', `${DOCS_BASE_URL}${src}`);
235
- }
236
- });
237
- }
238
- /**
239
- * Extract product and version from URL
240
- * Supports both old docs.jamf.com and new learn.jamf.com URL structures
241
- */
242
- function extractProductInfo(url) {
243
- // learn.jamf.com structure: /{locale}/bundle/{product}-documentation/page/{page}.html
244
- const bundleMatch = /\/bundle\/([^/]+)-documentation\//.exec(url);
245
- if (bundleMatch !== null) {
246
- const product = Object.values(JAMF_PRODUCTS).find(p => p.bundleId.includes(bundleMatch[1] ?? ''));
247
- return { product: product?.name, version: 'current' };
248
- }
249
- // Legacy docs.jamf.com structure: /{version}/{product}/...
250
- const pathParts = new URL(url).pathname.split('/').filter(Boolean);
251
- const versionMatch = pathParts[0]?.match(/^\d+\.\d+(\.\d+)?$/);
252
- if (versionMatch !== null && versionMatch !== undefined) {
253
- const product = Object.values(JAMF_PRODUCTS).find(p => p.urlPattern.includes(pathParts[1] ?? ''));
254
- return { product: product?.name, version: pathParts[0] };
255
- }
256
- // Check for unversioned paths
257
- const matchedProduct = Object.values(JAMF_PRODUCTS).find(p => url.includes(p.bundleId) || url.includes(p.id));
258
- return { product: matchedProduct?.name, version: matchedProduct !== undefined ? 'current' : undefined };
259
- }
260
- /** Pre-computed lowercase keywords per topic (avoids repeated toLowerCase on static data) */
261
- const TOPIC_KEYWORDS_LOWER = Object.fromEntries(Object.keys(JAMF_TOPICS).map(id => [
262
- id,
263
- JAMF_TOPICS[id].keywords.map(k => k.toLowerCase())
264
- ]));
265
- const ALL_TOPIC_IDS = Object.keys(JAMF_TOPICS);
266
- /**
267
- * Apply filters with progressive relaxation when results are zero
268
- * Relaxation order: docType → topic → product
269
- */
270
- function applyFiltersWithFallback(allResults, activeFilters) {
271
- // Apply all filters
272
- let filtered = allResults;
273
- for (const filter of activeFilters) {
274
- filtered = filter.apply(filtered);
275
- }
276
- if (filtered.length > 0 || activeFilters.length === 0) {
277
- return { filtered };
278
- }
279
- // Progressive relaxation
280
- const relaxOrder = ['docType', 'topic', 'product'];
281
- const removed = [];
282
- const original = {};
283
- for (const filterName of relaxOrder) {
284
- if (filtered.length > 0) {
285
- break;
286
- }
287
- const filterIndex = activeFilters.findIndex(f => f.name === filterName);
288
- if (filterIndex === -1) {
289
- continue;
290
- }
291
- const removedFilter = activeFilters[filterIndex];
292
- if (removedFilter === undefined) {
293
- continue;
294
- }
295
- removed.push(removedFilter.name);
296
- original[removedFilter.name] = removedFilter.value;
297
- activeFilters.splice(filterIndex, 1);
298
- // Re-apply remaining filters
299
- filtered = allResults;
300
- for (const filter of activeFilters) {
301
- filtered = filter.apply(filtered);
302
- }
303
- }
304
- if (removed.length > 0) {
305
- return {
306
- filtered,
307
- relaxation: {
308
- removed,
309
- original,
310
- message: `No results with all filters applied. Removed filter(s): ${removed.join(', ')}. Try broader search terms or fewer filters.`
311
- }
312
- };
313
- }
314
- return { filtered };
315
- }
316
- /**
317
- * Select the best result entry for the requested version from leading + followers.
318
- * Returns the matching entry's URL, bundle_id, and whether the match was exact.
319
- */
320
- function selectVersionedEntry(leading, followers, requestedVersion) {
321
- const leadingVersion = extractVersionFromBundleId(leading.bundle_id) ?? 'current';
322
- // No version filter or 'current' — use leading
323
- if (requestedVersion === undefined || requestedVersion === '' || requestedVersion === 'current') {
324
- return { url: leading.url, bundleId: leading.bundle_id, version: leadingVersion, versionMatched: true };
325
- }
326
- // Check if leading already matches
327
- if (leadingVersion === requestedVersion) {
328
- return { url: leading.url, bundleId: leading.bundle_id, version: leadingVersion, versionMatched: true };
329
- }
330
- // Search followers for matching version
331
- if (followers !== undefined && followers.length > 0) {
332
- for (const follower of followers) {
333
- const followerVersion = extractVersionFromBundleId(follower.bundle_id);
334
- if (followerVersion === requestedVersion) {
335
- return { url: follower.url, bundleId: follower.bundle_id, version: followerVersion, versionMatched: true };
336
- }
337
- }
338
- }
339
- // No match found — fallback to leading
340
- return { url: leading.url, bundleId: leading.bundle_id, version: leadingVersion, versionMatched: false };
341
- }
342
- /**
343
- * Transform a Zoomin leading result into a SearchResultWithMeta
344
- */
345
- function transformZoominResult(wrapper, requestedVersion) {
346
- const leading = wrapper.leading_result;
347
- const versioned = selectVersionedEntry(leading, wrapper.follower_result, requestedVersion);
348
- const { bundleId } = versioned;
349
- const bundleSlug = extractProductSlug(bundleId);
350
- const productEntry = bundleSlug !== null
351
- ? Object.entries(JAMF_PRODUCTS).find(([id]) => id === bundleSlug)
352
- : null;
353
- const resultTitle = leading.title !== '' ? leading.title : 'Untitled';
354
- const resultProduct = productEntry !== null && productEntry !== undefined
355
- ? productEntry[1].name
356
- : (leading.publication_title !== '' ? leading.publication_title : 'Jamf');
357
- const rawSnippet = stripHtml(leading.snippet !== '' ? leading.snippet : '').slice(0, CONTENT_LIMITS.MAX_SNIPPET_LENGTH);
358
- const { labels } = leading;
359
- const labelKeys = (labels ?? []).map(l => l.key);
360
- const searchResult = {
361
- title: resultTitle,
362
- url: transformToFrontendUrl(versioned.url !== '' ? versioned.url : ''),
363
- snippet: cleanSnippet(rawSnippet, resultTitle, resultProduct),
364
- product: resultProduct,
365
- version: versioned.version,
366
- docType: docTypeFromLabels(labels)
367
- };
368
- if (leading.score !== undefined) {
369
- searchResult.relevance = leading.score;
370
- }
371
- return {
372
- result: searchResult, bundleSlug, bundleId,
373
- matchedTopics: matchTopics(searchResult.title, searchResult.snippet),
374
- labelKeys,
375
- versionMatched: versioned.versionMatched
376
- };
377
- }
378
- /**
379
- * Match topic IDs from title + snippet text.
380
- */
381
- function matchTopics(title, snippet) {
382
- const searchText = `${title} ${snippet}`.toLowerCase();
383
- return ALL_TOPIC_IDS
384
- .filter(topicId => TOPIC_KEYWORDS_LOWER[topicId].some(kw => searchText.includes(kw)));
385
- }
386
- const BUNDLE_ID_REGEX = /\/bundle\/([^/]+)\//;
387
- /**
388
- * Convert a flat SearchResult (e.g. from a SearchProvider) into a
389
- * SearchResultWithMeta so it can flow through the core post-processing pipeline.
390
- */
391
- function toSearchResultWithMeta(result) {
392
- const bundleMatch = BUNDLE_ID_REGEX.exec(result.url);
393
- const bundleId = bundleMatch?.[1] ?? '';
394
- const bundleSlug = bundleId !== '' ? extractProductSlug(bundleId) : null;
395
- const labelKeys = result.docType !== undefined
396
- ? [DOC_TYPE_LABEL_MAP[result.docType]]
397
- : [];
398
- return {
399
- result,
400
- bundleSlug,
401
- bundleId,
402
- matchedTopics: matchTopics(result.title, result.snippet),
403
- labelKeys,
404
- versionMatched: true,
405
- };
406
- }
407
- /**
408
- * Build active filters from search params for progressive relaxation
409
- */
410
- function buildActiveFilters(params) {
411
- const activeFilters = [];
412
- if (params.product !== undefined) {
413
- activeFilters.push({
414
- name: 'product',
415
- value: params.product,
416
- apply: (results) => results.filter(r => r.bundleSlug === params.product)
417
- });
418
- }
419
- if (params.topic !== undefined) {
420
- const topicFilter = params.topic;
421
- activeFilters.push({
422
- name: 'topic',
423
- value: topicFilter,
424
- apply: (results) => results.filter(r => r.matchedTopics.includes(topicFilter))
425
- });
426
- }
427
- if (params.docType !== undefined) {
428
- const docTypeFilter = params.docType;
429
- const targetLabelKey = DOC_TYPE_LABEL_MAP[docTypeFilter];
430
- if (targetLabelKey !== undefined) {
431
- activeFilters.push({
432
- name: 'docType',
433
- value: docTypeFilter,
434
- apply: (results) => results.filter(r => {
435
- if (r.labelKeys.length === 0) {
436
- return true;
437
- }
438
- return r.labelKeys.includes(targetLabelKey);
439
- })
440
- });
441
- }
442
- }
443
- return activeFilters;
444
- }
445
- /**
446
- * Truncate search results to fit within token budget.
447
- * Returns the truncated results and info about omitted items.
448
- */
449
- function truncateSearchResults(paginatedResults, maxTokens) {
450
- const tokenCount = estimateTokens(paginatedResults.map(r => `${r.title}\n${r.snippet}\n${r.url}`).join('\n\n'));
451
- if (tokenCount <= maxTokens) {
452
- return { finalResults: paginatedResults, truncated: false };
453
- }
454
- let runningTokens = 0;
455
- const finalResults = [];
456
- for (const result of paginatedResults) {
457
- const resultTokens = estimateTokens(`${result.title}\n${result.snippet}\n${result.url}`);
458
- if (runningTokens + resultTokens > maxTokens) {
459
- break;
460
- }
461
- finalResults.push(result);
462
- runningTokens += resultTokens;
463
- }
464
- const omittedResults = paginatedResults.slice(finalResults.length);
465
- const truncatedContent = {
466
- omittedCount: omittedResults.length,
467
- omittedItems: omittedResults.map(r => ({
468
- title: r.title,
469
- estimatedTokens: estimateTokens(`${r.title}\n${r.snippet}\n${r.url}`)
470
- }))
471
- };
472
- return { finalResults, truncated: true, truncatedContent };
473
- }
474
- /**
475
- * Fetch search results from API or cache
476
- */
477
- async function fetchSearchResults(params, locale, cacheKey, cache, fetchJson, log) {
478
- const cached = await cache.get(cacheKey);
479
- if (cached !== null) {
480
- return cached;
481
- }
482
- const hasClientFilter = params.docType !== undefined || (params.version !== undefined && params.version !== 'current');
483
- const fetchLimit = hasClientFilter
484
- ? Math.min(CONTENT_LIMITS.MAX_SEARCH_RESULTS * CONTENT_LIMITS.FILTER_OVERFETCH_MULTIPLIER, CONTENT_LIMITS.FILTER_OVERFETCH_CAP)
485
- : CONTENT_LIMITS.MAX_SEARCH_RESULTS;
486
- const apiUrl = new URL(`${DOCS_API_URL}/api/search`);
487
- apiUrl.searchParams.set('q', params.query);
488
- apiUrl.searchParams.set('rpp', fetchLimit.toString());
489
- if (locale !== DEFAULT_LOCALE) {
490
- apiUrl.searchParams.set('lang', locale);
491
- }
492
- if (params.product !== undefined) {
493
- const productDef = JAMF_PRODUCTS[params.product];
494
- apiUrl.searchParams.set('label', productDef.searchLabel);
495
- }
496
- log.debug(`Query: "${params.query}", Product: ${params.product ?? 'all'}, Topic: ${params.topic ?? 'all'}, Locale: ${locale}, URL: ${apiUrl.toString()}`);
497
- const response = await fetchJson(apiUrl.toString(), locale);
498
- const results = response.Results
499
- .filter((wrapper) => wrapper.leading_result !== null && wrapper.leading_result !== undefined)
500
- .filter(wrapper => {
501
- const resultUrl = wrapper.leading_result.url;
502
- if (resultUrl !== '' && !isAllowedHostname(resultUrl)) {
503
- log.warning(`Skipping result with unexpected hostname: ${resultUrl}`);
504
- return false;
505
- }
506
- return true;
507
- })
508
- .map(wrapper => transformZoominResult(wrapper, params.version));
509
- await cache.set(cacheKey, results);
510
- return results;
511
- }
512
- /**
513
- * Normalize a title for deduplication: lowercase, strip special chars, collapse whitespace.
514
- */
515
- function normalizeTitle(title) {
516
- return title
517
- .toLowerCase()
518
- .replace(/[^a-z0-9\s]/g, '')
519
- .replace(/\s+/g, ' ')
520
- .trim();
521
- }
522
- /**
523
- * Deduplicate search results by product + page slug, keeping the latest version.
524
- * Falls back to title-based dedup when page slugs differ but titles match.
525
- * Only applied when no version filter is specified.
526
- */
527
- function deduplicateByLatestVersion(results) {
528
- const seen = new Map();
529
- const titleIndex = new Map(); // titleKey → slug key
530
- for (const item of results) {
531
- const pageMatch = /\/page\/([^?#]+)/.exec(item.result.url);
532
- if (pageMatch === null) {
533
- seen.set(`_no_page_${seen.size}`, item);
534
- continue;
535
- }
536
- const pageSlug = pageMatch[1] ?? '';
537
- const productSlug = item.bundleSlug ?? '_unknown';
538
- const slugKey = `${productSlug}:${pageSlug}`;
539
- const titleKey = `${productSlug}:title:${normalizeTitle(item.result.title)}`;
540
- // Check slug key first, then fall back to title key
541
- const existingSlugKey = seen.has(slugKey) ? slugKey : null;
542
- const existingTitleRef = titleIndex.get(titleKey);
543
- const matchKey = existingSlugKey ?? (existingTitleRef !== undefined && seen.has(existingTitleRef) ? existingTitleRef : null);
544
- if (matchKey === null) {
545
- seen.set(slugKey, item);
546
- titleIndex.set(titleKey, slugKey);
547
- }
548
- else {
549
- const existing = seen.get(matchKey);
550
- const existingVersion = existing.result.version ?? '';
551
- const newVersion = item.result.version ?? '';
552
- if (compareVersions(newVersion, existingVersion) > 0) {
553
- seen.set(matchKey, item);
554
- }
555
- }
556
- }
557
- const kept = new Set(seen.values());
558
- return results.filter(r => kept.has(r));
559
- }
560
- /**
561
- * Resolve search results from provider or Zoomin API fallback.
562
- */
563
- async function resolveSearchResults(ctx, params, log) {
564
- if (ctx.searchProvider) {
565
- const provided = await ctx.searchProvider.search(params);
566
- if (provided !== null) {
567
- return provided.map(toSearchResultWithMeta);
568
- }
569
- }
570
- const fetchJson = makeFetchJson(ctx.config.request);
571
- const locale = params.language ?? DEFAULT_LOCALE;
572
- const cacheKey = `${locale}:search:${JSON.stringify({
573
- query: params.query, product: params.product, topic: params.topic,
574
- docType: params.docType, version: params.version, limit: params.limit,
575
- maxTokens: params.maxTokens, page: 1
576
- })}`;
577
- return fetchSearchResults(params, locale, cacheKey, ctx.cache, fetchJson, log);
578
- }
579
- /**
580
- * Search Jamf documentation using Zoomin Search API
581
- */
582
- export async function searchDocumentation(ctx, params) {
583
- const log = ctx.logger.createLogger('scraper');
584
- const page = params.page ?? PAGINATION_CONFIG.DEFAULT_PAGE;
585
- const pageSize = params.limit ?? CONTENT_LIMITS.DEFAULT_SEARCH_RESULTS;
586
- const maxTokens = params.maxTokens ?? TOKEN_CONFIG.DEFAULT_MAX_TOKENS;
587
- let allResults;
588
- try {
589
- allResults = await resolveSearchResults(ctx, params, log);
590
- }
591
- catch (error) {
592
- log.error(`Search error: ${String(error)}`);
593
- if (error instanceof JamfDocsError) {
594
- throw error;
595
- }
596
- allResults = [];
597
- }
598
- // Build and apply filters with progressive relaxation
599
- const activeFilters = buildActiveFilters(params);
600
- const { filtered: filteredResults, relaxation: filterRelaxation } = applyFiltersWithFallback(allResults, activeFilters);
601
- // Deduplicate cross-version results when no version filter
602
- const dedupedResults = params.version === undefined || params.version === ''
603
- ? deduplicateByLatestVersion(filteredResults)
604
- : filteredResults;
605
- // Calculate pagination
606
- const paginationInfo = calculatePagination(dedupedResults.length, page, pageSize);
607
- // Get paginated results
608
- const paginatedResults = dedupedResults
609
- .slice(paginationInfo.startIndex, paginationInfo.endIndex)
610
- .map(r => r.result);
611
- // Truncate results to fit within token budget
612
- const { finalResults, truncated, truncatedContent } = truncateSearchResults(paginatedResults, maxTokens);
613
- const finalTokenCount = estimateTokens(finalResults.map(r => `${r.title}\n${r.snippet}\n${r.url}`).join('\n\n'));
614
- // Generate versionNote only when version was requested but not found in some results
615
- const requestedVersion = params.version;
616
- const hasVersionMismatch = requestedVersion !== undefined
617
- && requestedVersion !== 'current'
618
- && requestedVersion !== ''
619
- && dedupedResults.some(r => !r.versionMatched);
620
- const versionNote = hasVersionMismatch
621
- ? `Version "${requestedVersion}" was not available for some results. Showing the latest version instead.`
622
- : undefined;
623
- return {
624
- results: finalResults,
625
- pagination: {
626
- page: paginationInfo.page,
627
- pageSize: paginationInfo.pageSize,
628
- totalPages: paginationInfo.totalPages,
629
- totalItems: dedupedResults.length,
630
- hasNext: paginationInfo.hasNext,
631
- hasPrev: paginationInfo.hasPrev
632
- },
633
- tokenInfo: {
634
- tokenCount: finalTokenCount,
635
- truncated,
636
- maxTokens
637
- },
638
- ...(filterRelaxation !== undefined ? { filterRelaxation } : {}),
639
- ...(versionNote !== undefined ? { versionNote } : {}),
640
- ...(truncatedContent !== undefined ? { truncatedContent } : {})
641
- };
642
- }
643
- /**
644
- * Fetch and parse a documentation article
645
- * Uses backend URL (learn-be.jamf.com) for pre-rendered content
646
- */
647
- export async function fetchArticle(ctx, url, options = {}) {
648
- if (ctx.articleProvider) {
649
- const provided = await ctx.articleProvider.getArticle(url, options);
650
- if (provided !== null)
651
- return provided;
652
- }
653
- const fetchHtmlInner = makeFetchHtml(ctx.config.request);
654
- const maxTokens = options.maxTokens ?? TOKEN_CONFIG.DEFAULT_MAX_TOKENS;
655
- // Infer locale from URL if not explicitly provided (e.g., /ja-JP/bundle/...)
656
- const locale = options.locale ?? extractLocaleFromUrl(url);
657
- // Strip locale prefix before transforming — backend doesn't accept /en-US/ etc. in path
658
- const cleanUrl = stripLocalePrefix(url);
659
- const displayUrl = transformToFrontendUrl(cleanUrl);
660
- const articleFetchUrl = transformToBackendUrl(cleanUrl);
661
- const cacheKey = `${locale}:article:${displayUrl}`;
662
- // Check cache for raw article (without section/token processing)
663
- let rawArticle = await ctx.cache.get(cacheKey);
664
- if (rawArticle === null) {
665
- // Fetch HTML from backend URL (pre-rendered content, not SPA shell)
666
- const html = await fetchHtmlInner(articleFetchUrl, locale);
667
- const $ = cheerio.load(html);
668
- // Clean content
669
- cleanHtml($);
670
- // Extract content
671
- const contentHtml = $(SELECTORS.CONTENT).html() ?? '';
672
- const extractedTitle = $(SELECTORS.TITLE).first().text().trim();
673
- const title = extractedTitle !== '' ? extractedTitle : 'Untitled';
674
- // Convert to Markdown and strip Turndown anchor artifacts from headings
675
- // Turndown converts HTML anchors like <a href="#">...</a> inside headings to [](#)
676
- const content = turndown.turndown(contentHtml)
677
- .replace(/^(#{1,6}\s+)\[([^\]]*)\]\(#[^)]*\)/gm, '$1$2');
678
- // Extract breadcrumb
679
- const breadcrumb = $(SELECTORS.BREADCRUMB)
680
- .map((_, el) => $(el).text().trim())
681
- .get()
682
- .filter(Boolean);
683
- // Extract related articles
684
- const relatedArticles = options.includeRelated === true
685
- ? $(SELECTORS.RELATED).map((_, el) => {
686
- const rawHref = $(el).attr('href') ?? '';
687
- // Skip empty or hash-only URLs (same-page anchors)
688
- if (rawHref === '' || rawHref.startsWith('#')) {
689
- return { title: '', url: '' };
690
- }
691
- // Resolve relative URLs against the article's fetch URL
692
- let resolvedUrl;
693
- try {
694
- resolvedUrl = new URL(rawHref, articleFetchUrl).toString();
695
- }
696
- catch {
697
- resolvedUrl = rawHref;
698
- }
699
- return {
700
- title: $(el).text().trim(),
701
- url: transformToFrontendUrl(resolvedUrl)
702
- };
703
- }).get().filter(r => r.title !== '' && r.url !== '')
704
- : undefined;
705
- // Extract product info from URL
706
- const { product, version } = extractProductInfo(displayUrl);
707
- rawArticle = {
708
- title,
709
- content,
710
- url: displayUrl,
711
- product,
712
- version,
713
- breadcrumb: breadcrumb.length > 0 ? breadcrumb : undefined,
714
- relatedArticles: relatedArticles !== undefined && relatedArticles.length > 0 ? relatedArticles : undefined
715
- };
716
- // Cache raw article
717
- await ctx.cache.set(cacheKey, rawArticle);
718
- }
719
- // At this point rawArticle is guaranteed to be non-null
720
- const article = rawArticle;
721
- // Extract all sections
722
- const allSections = extractSections(article.content);
723
- // Handle summaryOnly mode
724
- if (options.summaryOnly === true) {
725
- const summaryResult = extractSummary(article.content, article.title, maxTokens);
726
- // Build summary content
727
- let summaryContent = `## Summary\n\n${summaryResult.summary}\n\n`;
728
- summaryContent += `## Article Outline (${summaryResult.outline.length} sections)\n\n`;
729
- for (const section of summaryResult.outline) {
730
- const indent = ' '.repeat(Math.max(0, section.level - 1));
731
- summaryContent += `${indent}- ${section.title} (~${section.tokenCount} tokens)\n`;
732
- }
733
- summaryContent += `\n*Estimated read time: ${summaryResult.estimatedReadTime} min | Total: ${summaryResult.totalTokens.toLocaleString()} tokens*\n`;
734
- return {
735
- ...article,
736
- content: summaryContent,
737
- tokenInfo: summaryResult.tokenInfo,
738
- sections: allSections
739
- };
740
- }
741
- // Handle section extraction if requested
742
- let processedContent;
743
- let tokenInfo;
744
- if (options.section !== undefined && options.section !== '') {
745
- // Extract specific section
746
- const sectionResult = extractSection(article.content, options.section, maxTokens);
747
- processedContent = sectionResult.content;
748
- tokenInfo = sectionResult.tokenInfo;
749
- if (sectionResult.section === null) {
750
- // Section not found, return error info
751
- processedContent = `*Section "${options.section}" not found.*\n\n**Available sections:**\n${allSections.map(s => `- ${s.title}`).join('\n')}`;
752
- tokenInfo = createTokenInfo(processedContent, maxTokens);
753
- }
754
- }
755
- else {
756
- // Apply token limit with smart truncation
757
- const truncateResult = truncateToTokenLimit(article.content, maxTokens);
758
- processedContent = truncateResult.content;
759
- tokenInfo = truncateResult.tokenInfo;
760
- }
761
- return {
762
- ...article,
763
- content: processedContent,
764
- tokenInfo,
765
- sections: allSections
766
- };
767
- }
768
- /**
769
- * Discover the latest bundle version for a product via search API
770
- */
771
- async function discoverLatestBundleId(product, fetchJson, log) {
772
- const productInfo = JAMF_PRODUCTS[product];
773
- const baseBundleId = productInfo.bundleId;
774
- try {
775
- // Search for any document from this product to discover the latest version
776
- const apiUrl = `${DOCS_API_URL}/api/search?q=${encodeURIComponent(productInfo.name)}&rpp=10`;
777
- const response = await fetchJson(apiUrl);
778
- // Find a result with a matching bundle prefix
779
- for (const wrapper of response.Results) {
780
- if (wrapper.leading_result === null || wrapper.leading_result === undefined) {
781
- continue;
782
- }
783
- const bundleId = wrapper.leading_result.bundle_id;
784
- if (bundleId.startsWith(baseBundleId)) {
785
- return validateBundleId(bundleId);
786
- }
787
- }
788
- }
789
- catch (error) {
790
- log.error(`Error discovering bundle version for ${product}: ${String(error)}`);
791
- }
792
- return null;
793
- }
794
- /**
795
- * Parse TOC HTML from Zoomin backend
796
- */
797
- function parseTocHtml(html) {
798
- const $ = cheerio.load(html);
799
- const toc = [];
800
- // Parse the nested list structure
801
- $('ul.list-links > li.toc').each((_, el) => {
802
- const entry = parseTocEntry($, el);
803
- if (entry !== null) {
804
- toc.push(entry);
805
- }
806
- });
807
- return toc;
808
- }
809
- function parseTocEntry($, el) {
810
- const $el = $(el);
811
- const $link = $el.children('.inner').children('a').first();
812
- const title = $link.text().trim();
813
- let url = $link.attr('href') ?? '';
814
- if (title === '' || url === '') {
815
- return null;
816
- }
817
- // Transform to frontend URL for display
818
- url = transformToFrontendUrl(url);
819
- const entry = { title, url };
820
- // Check for children
821
- const $children = $el.children('ul.list-links');
822
- if ($children.length > 0) {
823
- const children = [];
824
- $children.children('li.toc').each((_, childEl) => {
825
- const childEntry = parseTocEntry($, childEl);
826
- if (childEntry !== null) {
827
- children.push(childEntry);
828
- }
829
- });
830
- if (children.length > 0) {
831
- entry.children = children;
832
- }
833
- }
834
- return entry;
835
- }
836
- /**
837
- * Convert TOC entry to string for token counting
838
- */
839
- function tocEntryToString(entry, depth = 0) {
840
- const indent = ' '.repeat(depth);
841
- const childrenStr = entry.children?.map(c => tocEntryToString(c, depth + 1)).join('') ?? '';
842
- return `${indent}- ${entry.title}\n${childrenStr}`;
843
- }
844
- /**
845
- * Count total TOC entries including children
846
- */
847
- function countTocEntries(entries) {
848
- return entries.reduce((count, entry) => count + 1 + (entry.children !== undefined ? countTocEntries(entry.children) : 0), 0);
849
- }
850
- /**
851
- * Build a flat TOC from search results when backend TOC endpoint returns empty.
852
- * Searches for all articles under the given product and constructs TocEntry list.
853
- */
854
- async function buildTocFromSearch(product, locale, fetchJson, log) {
855
- try {
856
- const apiUrl = new URL(`${DOCS_API_URL}/api/search`);
857
- apiUrl.searchParams.set('q', JAMF_PRODUCTS[product].name);
858
- apiUrl.searchParams.set('rpp', CONTENT_LIMITS.MAX_SEARCH_RESULTS.toString());
859
- if (locale !== DEFAULT_LOCALE) {
860
- apiUrl.searchParams.set('lang', locale);
861
- }
862
- const response = await fetchJson(apiUrl.toString(), locale);
863
- const seen = new Set();
864
- const entries = [];
865
- for (const wrapper of response.Results) {
866
- const leading = wrapper.leading_result;
867
- if (leading === null || leading === undefined) {
868
- continue;
869
- }
870
- const bundleSlug = extractProductSlug(leading.bundle_id);
871
- if (bundleSlug !== product) {
872
- continue;
873
- }
874
- const url = transformToFrontendUrl(leading.url);
875
- if (seen.has(url)) {
876
- continue;
877
- }
878
- seen.add(url);
879
- const title = leading.title !== '' ? leading.title : 'Untitled';
880
- entries.push({ title, url });
881
- }
882
- if (entries.length > 0) {
883
- log.info(`Built fallback TOC with ${entries.length} entries from search for ${product}`);
884
- }
885
- return entries;
886
- }
887
- catch (error) {
888
- log.error(`Failed to build TOC from search for ${product}: ${String(error)}`);
889
- return [];
890
- }
891
- }
892
- /**
893
- * Fetch table of contents for a product
894
- * Uses backend TOC endpoint (learn-be.jamf.com/bundle/{bundleId}/toc)
895
- */
896
- export async function fetchTableOfContents(ctx, product, version = 'current', options = {}) {
897
- if (ctx.tocProvider) {
898
- const provided = await ctx.tocProvider.getTableOfContents(product, version, options);
899
- if (provided !== null)
900
- return provided;
901
- }
902
- const log = ctx.logger.createLogger('scraper');
903
- const fetchJson = makeFetchJson(ctx.config.request);
904
- const page = options.page ?? PAGINATION_CONFIG.DEFAULT_PAGE;
905
- const maxTokens = options.maxTokens ?? TOKEN_CONFIG.DEFAULT_MAX_TOKENS;
906
- const locale = options.locale ?? DEFAULT_LOCALE;
907
- const cacheKey = `${locale}:toc:${product}:${version}`;
908
- // Check cache
909
- let allToc = await ctx.cache.get(cacheKey);
910
- if (allToc === null) {
911
- // Get bundle ID for the specified version (or latest if current)
912
- let bundleId = await getBundleIdForVersion(ctx, product, version);
913
- // Fallback to discovery if metadata service fails
914
- bundleId ??= await discoverLatestBundleId(product, fetchJson, log);
915
- if (bundleId === null || bundleId === '') {
916
- throw new JamfDocsError(`Could not find bundle for ${product} version ${version}`, JamfDocsErrorCode.NOT_FOUND);
917
- }
918
- // Fetch TOC from backend (may 404 for some products)
919
- const tocUrl = `${DOCS_API_URL}/bundle/${bundleId}/toc`;
920
- log.debug(`Fetching TOC from: ${tocUrl}, Locale: ${locale}`);
921
- allToc = [];
922
- try {
923
- const tocJson = await fetchJson(tocUrl, locale);
924
- // Parse TOC
925
- for (const [, html] of Object.entries(tocJson)) {
926
- if (typeof html === 'string' && html.includes('<ul')) {
927
- const entries = parseTocHtml(html);
928
- allToc.push(...entries);
929
- }
930
- }
931
- }
932
- catch (error) {
933
- // TOC endpoint may 404 or fail for some products — fall through to search fallback
934
- log.error(`Backend TOC request failed for ${product}: ${error instanceof Error ? error.message : String(error)}`);
935
- }
936
- // Fallback: build TOC from search results when backend returns empty or fails
937
- if (allToc.length === 0) {
938
- log.info(`No TOC entries for ${product}, falling back to search-based discovery`);
939
- allToc = await buildTocFromSearch(product, locale, fetchJson, log);
940
- }
941
- // Cache result
942
- await ctx.cache.set(cacheKey, allToc);
943
- }
944
- // Count total entries
945
- const totalItems = countTocEntries(allToc);
946
- // For TOC, we paginate at the top-level entries
947
- const topLevelCount = allToc.length;
948
- const pageSize = PAGINATION_CONFIG.DEFAULT_PAGE_SIZE;
949
- const paginationInfo = calculatePagination(topLevelCount, page, pageSize);
950
- // Get paginated top-level entries (with their children)
951
- const paginatedToc = allToc.slice(paginationInfo.startIndex, paginationInfo.endIndex);
952
- // Calculate tokens for paginated TOC
953
- const tocText = paginatedToc.map(e => tocEntryToString(e)).join('');
954
- let tokenCount = estimateTokens(tocText);
955
- let truncated = false;
956
- let finalToc = paginatedToc;
957
- // Truncate if over token limit
958
- if (tokenCount > maxTokens) {
959
- truncated = true;
960
- finalToc = [];
961
- let runningTokens = 0;
962
- for (const entry of paginatedToc) {
963
- const entryText = tocEntryToString(entry);
964
- const entryTokens = estimateTokens(entryText);
965
- if (runningTokens + entryTokens > maxTokens) {
966
- break;
967
- }
968
- finalToc.push(entry);
969
- runningTokens += entryTokens;
970
- }
971
- tokenCount = runningTokens;
972
- }
973
- return {
974
- toc: finalToc,
975
- pagination: {
976
- page: paginationInfo.page,
977
- pageSize: paginationInfo.pageSize,
978
- totalPages: paginationInfo.totalPages,
979
- totalItems,
980
- hasNext: paginationInfo.hasNext,
981
- hasPrev: paginationInfo.hasPrev
982
- },
983
- tokenInfo: {
984
- tokenCount,
985
- truncated,
986
- maxTokens
987
- }
988
- };
989
- }
990
- //# sourceMappingURL=scraper.js.map