@get-technology-inc/jamf-docs-mcp-server 1.4.1 → 1.5.1
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/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +6 -4
- package/dist/constants.js.map +1 -1
- package/dist/index.js +17 -5
- package/dist/index.js.map +1 -1
- package/dist/resources/index.d.ts.map +1 -1
- package/dist/resources/index.js +3 -1
- package/dist/resources/index.js.map +1 -1
- package/dist/schemas/index.d.ts +30 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +49 -0
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/output.d.ts +30 -0
- package/dist/schemas/output.d.ts.map +1 -1
- package/dist/schemas/output.js +27 -0
- package/dist/schemas/output.js.map +1 -1
- package/dist/services/cache.d.ts.map +1 -1
- package/dist/services/cache.js +3 -1
- package/dist/services/cache.js.map +1 -1
- package/dist/services/glossary.d.ts +45 -0
- package/dist/services/glossary.d.ts.map +1 -0
- package/dist/services/glossary.js +323 -0
- package/dist/services/glossary.js.map +1 -0
- package/dist/services/logging.d.ts +32 -0
- package/dist/services/logging.d.ts.map +1 -0
- package/dist/services/logging.js +61 -0
- package/dist/services/logging.js.map +1 -0
- package/dist/services/metadata.d.ts.map +1 -1
- package/dist/services/metadata.js +8 -6
- package/dist/services/metadata.js.map +1 -1
- package/dist/services/scraper.d.ts +12 -0
- package/dist/services/scraper.d.ts.map +1 -1
- package/dist/services/scraper.js +242 -166
- package/dist/services/scraper.js.map +1 -1
- package/dist/services/tokenizer.d.ts +5 -0
- package/dist/services/tokenizer.d.ts.map +1 -1
- package/dist/services/tokenizer.js +30 -14
- package/dist/services/tokenizer.js.map +1 -1
- package/dist/tools/batch-get-articles.d.ts +14 -0
- package/dist/tools/batch-get-articles.d.ts.map +1 -0
- package/dist/tools/batch-get-articles.js +240 -0
- package/dist/tools/batch-get-articles.js.map +1 -0
- package/dist/tools/get-article.d.ts.map +1 -1
- package/dist/tools/get-article.js +6 -4
- package/dist/tools/get-article.js.map +1 -1
- package/dist/tools/get-toc.d.ts.map +1 -1
- package/dist/tools/get-toc.js +27 -18
- package/dist/tools/get-toc.js.map +1 -1
- package/dist/tools/glossary-lookup.d.ts +7 -0
- package/dist/tools/glossary-lookup.d.ts.map +1 -0
- package/dist/tools/glossary-lookup.js +183 -0
- package/dist/tools/glossary-lookup.js.map +1 -0
- package/dist/tools/list-products.d.ts.map +1 -1
- package/dist/tools/list-products.js +8 -1
- package/dist/tools/list-products.js.map +1 -1
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +93 -62
- package/dist/tools/search.js.map +1 -1
- package/dist/transport/http.d.ts.map +1 -1
- package/dist/transport/http.js +57 -8
- package/dist/transport/http.js.map +1 -1
- package/dist/transport/index.d.ts.map +1 -1
- package/dist/transport/index.js +4 -2
- package/dist/transport/index.js.map +1 -1
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/bundle.d.ts +5 -0
- package/dist/utils/bundle.d.ts.map +1 -1
- package/dist/utils/bundle.js +29 -0
- package/dist/utils/bundle.js.map +1 -1
- package/dist/utils/concurrency.d.ts +6 -0
- package/dist/utils/concurrency.d.ts.map +1 -0
- package/dist/utils/concurrency.js +24 -0
- package/dist/utils/concurrency.js.map +1 -0
- package/dist/utils/progress.d.ts +7 -1
- package/dist/utils/progress.d.ts.map +1 -1
- package/dist/utils/progress.js +16 -5
- package/dist/utils/progress.js.map +1 -1
- package/package.json +2 -1
package/dist/services/scraper.js
CHANGED
|
@@ -7,15 +7,17 @@
|
|
|
7
7
|
import axios from 'axios';
|
|
8
8
|
import * as cheerio from 'cheerio';
|
|
9
9
|
import { sanitizeErrorMessage } from '../utils/sanitize.js';
|
|
10
|
-
import { isAllowedHostname } from '../utils/url.js';
|
|
10
|
+
import { isAllowedHostname, extractLocaleFromUrl } from '../utils/url.js';
|
|
11
11
|
import TurndownService from 'turndown';
|
|
12
|
-
import { DOCS_BASE_URL, DOCS_API_URL, JAMF_PRODUCTS, JAMF_TOPICS, DOC_TYPE_LABEL_MAP, REQUEST_CONFIG, SELECTORS, CONTENT_LIMITS, TOKEN_CONFIG, PAGINATION_CONFIG, DEFAULT_LOCALE } from '../constants.js';
|
|
12
|
+
import { DOCS_BASE_URL, DOCS_API_URL, JAMF_PRODUCTS, JAMF_TOPICS, DOC_TYPE_LABEL_MAP, REQUEST_CONFIG, SELECTORS, CONTENT_LIMITS, TOKEN_CONFIG, PAGINATION_CONFIG, DEFAULT_LOCALE, SUPPORTED_LOCALES } from '../constants.js';
|
|
13
13
|
import { JamfDocsError, JamfDocsErrorCode } from '../types.js';
|
|
14
|
-
import { extractVersionFromBundleId, extractProductSlug } from '../utils/bundle.js';
|
|
14
|
+
import { extractVersionFromBundleId, extractProductSlug, compareVersions } from '../utils/bundle.js';
|
|
15
15
|
import { docTypeFromLabels } from '../utils/doc-type.js';
|
|
16
16
|
import { cache } from './cache.js';
|
|
17
|
+
import { createLogger } from './logging.js';
|
|
17
18
|
import { estimateTokens, createTokenInfo, extractSections, extractSection, extractSummary, truncateToTokenLimit, calculatePagination } from './tokenizer.js';
|
|
18
19
|
import { getBundleIdForVersion } from './metadata.js';
|
|
20
|
+
const log = createLogger('scraper');
|
|
19
21
|
// Initialize Turndown for HTML to Markdown conversion
|
|
20
22
|
const turndown = new TurndownService({
|
|
21
23
|
headingStyle: 'atx',
|
|
@@ -47,15 +49,34 @@ async function throttle() {
|
|
|
47
49
|
}
|
|
48
50
|
// Re-export URL utilities for backward compatibility
|
|
49
51
|
export { ALLOWED_HOSTNAMES, isAllowedHostname } from '../utils/url.js';
|
|
52
|
+
/**
|
|
53
|
+
* Strip locale prefix (e.g. /en-US/, /ja-JP/) from URL path.
|
|
54
|
+
* Frontend URLs include locale in path but backend doesn't accept it.
|
|
55
|
+
*/
|
|
56
|
+
export function stripLocalePrefix(urlStr) {
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(urlStr);
|
|
59
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
60
|
+
const firstSegment = segments[0];
|
|
61
|
+
if (firstSegment !== undefined && firstSegment in SUPPORTED_LOCALES) {
|
|
62
|
+
url.pathname = `/${segments.slice(1).join('/')}`;
|
|
63
|
+
return url.toString();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Invalid URL, return as-is
|
|
68
|
+
}
|
|
69
|
+
return urlStr;
|
|
70
|
+
}
|
|
50
71
|
// URL transformation between frontend (learn.jamf.com) and backend (learn-be.jamf.com)
|
|
51
|
-
function transformToBackendUrl(urlStr) {
|
|
72
|
+
export function transformToBackendUrl(urlStr) {
|
|
52
73
|
const url = new URL(urlStr);
|
|
53
74
|
if (url.hostname === 'learn.jamf.com') {
|
|
54
75
|
url.hostname = 'learn-be.jamf.com';
|
|
55
76
|
}
|
|
56
77
|
return url.toString();
|
|
57
78
|
}
|
|
58
|
-
function transformToFrontendUrl(urlStr) {
|
|
79
|
+
export function transformToFrontendUrl(urlStr) {
|
|
59
80
|
const url = new URL(urlStr);
|
|
60
81
|
if (url.hostname === 'learn-be.jamf.com') {
|
|
61
82
|
url.hostname = 'learn.jamf.com';
|
|
@@ -140,7 +161,7 @@ function handleAxiosError(error, url, resourceType) {
|
|
|
140
161
|
/**
|
|
141
162
|
* Build Accept-Language header value for a given locale
|
|
142
163
|
*/
|
|
143
|
-
function buildAcceptLanguage(locale) {
|
|
164
|
+
export function buildAcceptLanguage(locale) {
|
|
144
165
|
if (locale === 'en-US') {
|
|
145
166
|
return 'en-US,en;q=0.9';
|
|
146
167
|
}
|
|
@@ -170,7 +191,7 @@ async function fetchUrl(url, accept, resourceType, locale) {
|
|
|
170
191
|
}
|
|
171
192
|
}
|
|
172
193
|
const fetchJson = async (url, locale) => await fetchUrl(url, 'application/json', 'Resource', locale);
|
|
173
|
-
const fetchHtml = async (url, locale) => await fetchUrl(url, 'text/html,application/xhtml+xml', 'Article', locale);
|
|
194
|
+
export const fetchHtml = async (url, locale) => await fetchUrl(url, 'text/html,application/xhtml+xml', 'Article', locale);
|
|
174
195
|
/**
|
|
175
196
|
* Clean HTML content by removing unwanted elements
|
|
176
197
|
*/
|
|
@@ -296,108 +317,46 @@ function selectVersionedEntry(leading, followers, requestedVersion) {
|
|
|
296
317
|
return { url: leading.url, bundleId: leading.bundle_id, version: leadingVersion, versionMatched: false };
|
|
297
318
|
}
|
|
298
319
|
/**
|
|
299
|
-
*
|
|
320
|
+
* Transform a Zoomin leading result into a SearchResultWithMeta
|
|
300
321
|
*/
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
console.error(`[SEARCH] Query: "${params.query}", Product: ${params.product ?? 'all'}, Topic: ${params.topic ?? 'all'}, Locale: ${locale}, URL: ${apiUrl.toString()}`);
|
|
327
|
-
try {
|
|
328
|
-
const response = await fetchJson(apiUrl.toString(), locale);
|
|
329
|
-
// Transform Zoomin results to SearchResult format with metadata
|
|
330
|
-
allResults = response.Results
|
|
331
|
-
.filter((wrapper) => wrapper.leading_result !== null && wrapper.leading_result !== undefined)
|
|
332
|
-
.filter(wrapper => {
|
|
333
|
-
// Validate URL hostname from external API
|
|
334
|
-
const resultUrl = wrapper.leading_result.url;
|
|
335
|
-
if (resultUrl !== '' && !isAllowedHostname(resultUrl)) {
|
|
336
|
-
console.error(`[SEARCH] Skipping result with unexpected hostname: ${resultUrl}`);
|
|
337
|
-
return false;
|
|
338
|
-
}
|
|
339
|
-
return true;
|
|
340
|
-
})
|
|
341
|
-
.map(wrapper => {
|
|
342
|
-
const leading = wrapper.leading_result;
|
|
343
|
-
// Select versioned entry (leading or matching follower)
|
|
344
|
-
const versioned = selectVersionedEntry(leading, wrapper.follower_result, params.version);
|
|
345
|
-
// Extract product from bundle_id (works for all bundle types: documentation, release-notes, etc.)
|
|
346
|
-
const { bundleId } = versioned;
|
|
347
|
-
const bundleSlug = extractProductSlug(bundleId);
|
|
348
|
-
const productEntry = bundleSlug !== null
|
|
349
|
-
? Object.entries(JAMF_PRODUCTS).find(([id]) => id === bundleSlug)
|
|
350
|
-
: null;
|
|
351
|
-
const resultTitle = leading.title !== '' ? leading.title : 'Untitled';
|
|
352
|
-
const resultProduct = productEntry !== null && productEntry !== undefined ? productEntry[1].name : (leading.publication_title !== '' ? leading.publication_title : 'Jamf');
|
|
353
|
-
const rawSnippet = stripHtml(leading.snippet !== '' ? leading.snippet : '').slice(0, CONTENT_LIMITS.MAX_SNIPPET_LENGTH);
|
|
354
|
-
// Derive docType from labels instead of bundle_id regex
|
|
355
|
-
const { labels } = leading;
|
|
356
|
-
const labelKeys = (labels ?? []).map(l => l.key);
|
|
357
|
-
const searchResult = {
|
|
358
|
-
title: resultTitle,
|
|
359
|
-
url: transformToFrontendUrl(versioned.url !== '' ? versioned.url : ''),
|
|
360
|
-
snippet: cleanSnippet(rawSnippet, resultTitle, resultProduct),
|
|
361
|
-
product: resultProduct,
|
|
362
|
-
version: versioned.version,
|
|
363
|
-
docType: docTypeFromLabels(labels)
|
|
364
|
-
};
|
|
365
|
-
if (leading.score !== undefined) {
|
|
366
|
-
searchResult.relevance = leading.score;
|
|
367
|
-
}
|
|
368
|
-
// Find matched topics (searchText lowercased once, keywords pre-computed)
|
|
369
|
-
const searchText = `${searchResult.title} ${searchResult.snippet}`.toLowerCase();
|
|
370
|
-
const matchedTopics = ALL_TOPIC_IDS
|
|
371
|
-
.filter(topicId => TOPIC_KEYWORDS_LOWER[topicId].some(kw => searchText.includes(kw)));
|
|
372
|
-
return {
|
|
373
|
-
result: searchResult, bundleSlug, bundleId, matchedTopics, labelKeys,
|
|
374
|
-
versionMatched: versioned.versionMatched
|
|
375
|
-
};
|
|
376
|
-
});
|
|
377
|
-
// Cache full results
|
|
378
|
-
await cache.set(cacheKey, allResults);
|
|
379
|
-
}
|
|
380
|
-
catch (error) {
|
|
381
|
-
console.error('[SEARCH] Error:', error);
|
|
382
|
-
if (error instanceof JamfDocsError) {
|
|
383
|
-
throw error;
|
|
384
|
-
}
|
|
385
|
-
// Return empty results on error
|
|
386
|
-
return {
|
|
387
|
-
results: [],
|
|
388
|
-
pagination: {
|
|
389
|
-
page: 1,
|
|
390
|
-
pageSize,
|
|
391
|
-
totalPages: 0,
|
|
392
|
-
totalItems: 0,
|
|
393
|
-
hasNext: false,
|
|
394
|
-
hasPrev: false
|
|
395
|
-
},
|
|
396
|
-
tokenInfo: createTokenInfo('', maxTokens)
|
|
397
|
-
};
|
|
398
|
-
}
|
|
322
|
+
function transformZoominResult(wrapper, requestedVersion) {
|
|
323
|
+
const leading = wrapper.leading_result;
|
|
324
|
+
const versioned = selectVersionedEntry(leading, wrapper.follower_result, requestedVersion);
|
|
325
|
+
const { bundleId } = versioned;
|
|
326
|
+
const bundleSlug = extractProductSlug(bundleId);
|
|
327
|
+
const productEntry = bundleSlug !== null
|
|
328
|
+
? Object.entries(JAMF_PRODUCTS).find(([id]) => id === bundleSlug)
|
|
329
|
+
: null;
|
|
330
|
+
const resultTitle = leading.title !== '' ? leading.title : 'Untitled';
|
|
331
|
+
const resultProduct = productEntry !== null && productEntry !== undefined
|
|
332
|
+
? productEntry[1].name
|
|
333
|
+
: (leading.publication_title !== '' ? leading.publication_title : 'Jamf');
|
|
334
|
+
const rawSnippet = stripHtml(leading.snippet !== '' ? leading.snippet : '').slice(0, CONTENT_LIMITS.MAX_SNIPPET_LENGTH);
|
|
335
|
+
const { labels } = leading;
|
|
336
|
+
const labelKeys = (labels ?? []).map(l => l.key);
|
|
337
|
+
const searchResult = {
|
|
338
|
+
title: resultTitle,
|
|
339
|
+
url: transformToFrontendUrl(versioned.url !== '' ? versioned.url : ''),
|
|
340
|
+
snippet: cleanSnippet(rawSnippet, resultTitle, resultProduct),
|
|
341
|
+
product: resultProduct,
|
|
342
|
+
version: versioned.version,
|
|
343
|
+
docType: docTypeFromLabels(labels)
|
|
344
|
+
};
|
|
345
|
+
if (leading.score !== undefined) {
|
|
346
|
+
searchResult.relevance = leading.score;
|
|
399
347
|
}
|
|
400
|
-
|
|
348
|
+
const searchText = `${searchResult.title} ${searchResult.snippet}`.toLowerCase();
|
|
349
|
+
const matchedTopics = ALL_TOPIC_IDS
|
|
350
|
+
.filter(topicId => TOPIC_KEYWORDS_LOWER[topicId].some(kw => searchText.includes(kw)));
|
|
351
|
+
return {
|
|
352
|
+
result: searchResult, bundleSlug, bundleId, matchedTopics, labelKeys,
|
|
353
|
+
versionMatched: versioned.versionMatched
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Build active filters from search params for progressive relaxation
|
|
358
|
+
*/
|
|
359
|
+
function buildActiveFilters(params) {
|
|
401
360
|
const activeFilters = [];
|
|
402
361
|
if (params.product !== undefined) {
|
|
403
362
|
activeFilters.push({
|
|
@@ -417,13 +376,11 @@ export async function searchDocumentation(params) {
|
|
|
417
376
|
if (params.docType !== undefined) {
|
|
418
377
|
const docTypeFilter = params.docType;
|
|
419
378
|
const targetLabelKey = DOC_TYPE_LABEL_MAP[docTypeFilter];
|
|
420
|
-
// Only apply filter if the docType maps to a known label key
|
|
421
379
|
if (targetLabelKey !== undefined) {
|
|
422
380
|
activeFilters.push({
|
|
423
381
|
name: 'docType',
|
|
424
382
|
value: docTypeFilter,
|
|
425
383
|
apply: (results) => results.filter(r => {
|
|
426
|
-
// If result has no labels, include it (don't filter out)
|
|
427
384
|
if (r.labelKeys.length === 0) {
|
|
428
385
|
return true;
|
|
429
386
|
}
|
|
@@ -432,56 +389,158 @@ export async function searchDocumentation(params) {
|
|
|
432
389
|
});
|
|
433
390
|
}
|
|
434
391
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
392
|
+
return activeFilters;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Truncate search results to fit within token budget.
|
|
396
|
+
* Returns the truncated results and info about omitted items.
|
|
397
|
+
*/
|
|
398
|
+
function truncateSearchResults(paginatedResults, maxTokens) {
|
|
399
|
+
const tokenCount = estimateTokens(paginatedResults.map(r => `${r.title}\n${r.snippet}\n${r.url}`).join('\n\n'));
|
|
400
|
+
if (tokenCount <= maxTokens) {
|
|
401
|
+
return { finalResults: paginatedResults, truncated: false };
|
|
402
|
+
}
|
|
403
|
+
let runningTokens = 0;
|
|
404
|
+
const finalResults = [];
|
|
405
|
+
for (const result of paginatedResults) {
|
|
406
|
+
const resultTokens = estimateTokens(`${result.title}\n${result.snippet}\n${result.url}`);
|
|
407
|
+
if (runningTokens + resultTokens > maxTokens) {
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
finalResults.push(result);
|
|
411
|
+
runningTokens += resultTokens;
|
|
412
|
+
}
|
|
413
|
+
const omittedResults = paginatedResults.slice(finalResults.length);
|
|
414
|
+
const truncatedContent = {
|
|
415
|
+
omittedCount: omittedResults.length,
|
|
416
|
+
omittedItems: omittedResults.map(r => ({
|
|
417
|
+
title: r.title,
|
|
418
|
+
estimatedTokens: estimateTokens(`${r.title}\n${r.snippet}\n${r.url}`)
|
|
419
|
+
}))
|
|
420
|
+
};
|
|
421
|
+
return { finalResults, truncated: true, truncatedContent };
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Fetch search results from API or cache
|
|
425
|
+
*/
|
|
426
|
+
async function fetchSearchResults(params, locale, cacheKey) {
|
|
427
|
+
const cached = await cache.get(cacheKey);
|
|
428
|
+
if (cached !== null) {
|
|
429
|
+
return cached;
|
|
430
|
+
}
|
|
431
|
+
const hasClientFilter = params.docType !== undefined || (params.version !== undefined && params.version !== 'current');
|
|
432
|
+
const fetchLimit = hasClientFilter
|
|
433
|
+
? Math.min(CONTENT_LIMITS.MAX_SEARCH_RESULTS * CONTENT_LIMITS.FILTER_OVERFETCH_MULTIPLIER, CONTENT_LIMITS.FILTER_OVERFETCH_CAP)
|
|
434
|
+
: CONTENT_LIMITS.MAX_SEARCH_RESULTS;
|
|
435
|
+
const apiUrl = new URL(`${DOCS_API_URL}/api/search`);
|
|
436
|
+
apiUrl.searchParams.set('q', params.query);
|
|
437
|
+
apiUrl.searchParams.set('rpp', fetchLimit.toString());
|
|
438
|
+
if (locale !== DEFAULT_LOCALE) {
|
|
439
|
+
apiUrl.searchParams.set('lang', locale);
|
|
440
|
+
}
|
|
441
|
+
log.debug(`Query: "${params.query}", Product: ${params.product ?? 'all'}, Topic: ${params.topic ?? 'all'}, Locale: ${locale}, URL: ${apiUrl.toString()}`);
|
|
442
|
+
const response = await fetchJson(apiUrl.toString(), locale);
|
|
443
|
+
const results = response.Results
|
|
444
|
+
.filter((wrapper) => wrapper.leading_result !== null && wrapper.leading_result !== undefined)
|
|
445
|
+
.filter(wrapper => {
|
|
446
|
+
const resultUrl = wrapper.leading_result.url;
|
|
447
|
+
if (resultUrl !== '' && !isAllowedHostname(resultUrl)) {
|
|
448
|
+
log.warning(`Skipping result with unexpected hostname: ${resultUrl}`);
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
return true;
|
|
452
|
+
})
|
|
453
|
+
.map(wrapper => transformZoominResult(wrapper, params.version));
|
|
454
|
+
await cache.set(cacheKey, results);
|
|
455
|
+
return results;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Deduplicate search results by product + page slug, keeping the latest version.
|
|
459
|
+
* Only applied when no version filter is specified.
|
|
460
|
+
*/
|
|
461
|
+
function deduplicateByLatestVersion(results) {
|
|
462
|
+
const seen = new Map();
|
|
463
|
+
for (const item of results) {
|
|
464
|
+
const pageMatch = /\/page\/([^?#]+)/.exec(item.result.url);
|
|
465
|
+
if (pageMatch === null) {
|
|
466
|
+
seen.set(`_no_page_${seen.size}`, item);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const pageSlug = pageMatch[1] ?? '';
|
|
470
|
+
const productSlug = item.bundleSlug ?? '_unknown';
|
|
471
|
+
const key = `${productSlug}:${pageSlug}`;
|
|
472
|
+
const existing = seen.get(key);
|
|
473
|
+
if (existing === undefined) {
|
|
474
|
+
seen.set(key, item);
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
const existingVersion = existing.result.version ?? '';
|
|
478
|
+
const newVersion = item.result.version ?? '';
|
|
479
|
+
if (compareVersions(newVersion, existingVersion) > 0) {
|
|
480
|
+
seen.set(key, item);
|
|
458
481
|
}
|
|
459
|
-
finalResults.push(result);
|
|
460
|
-
runningTokens += resultTokens;
|
|
461
482
|
}
|
|
462
483
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
484
|
+
const kept = new Set(seen.values());
|
|
485
|
+
return results.filter(r => kept.has(r));
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Search Jamf documentation using Zoomin Search API
|
|
489
|
+
*/
|
|
490
|
+
export async function searchDocumentation(params) {
|
|
491
|
+
const page = params.page ?? PAGINATION_CONFIG.DEFAULT_PAGE;
|
|
492
|
+
const pageSize = params.limit ?? CONTENT_LIMITS.DEFAULT_SEARCH_RESULTS;
|
|
493
|
+
const maxTokens = params.maxTokens ?? TOKEN_CONFIG.DEFAULT_MAX_TOKENS;
|
|
494
|
+
const locale = params.language ?? DEFAULT_LOCALE;
|
|
495
|
+
const cacheKey = `${locale}:search:${JSON.stringify({
|
|
496
|
+
query: params.query, product: params.product, topic: params.topic,
|
|
497
|
+
docType: params.docType, version: params.version, limit: params.limit,
|
|
498
|
+
maxTokens: params.maxTokens, page: 1
|
|
499
|
+
})}`;
|
|
500
|
+
let allResults;
|
|
501
|
+
try {
|
|
502
|
+
allResults = await fetchSearchResults(params, locale, cacheKey);
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
log.error(`Search error: ${String(error)}`);
|
|
506
|
+
if (error instanceof JamfDocsError) {
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
results: [],
|
|
511
|
+
pagination: {
|
|
512
|
+
page: 1,
|
|
513
|
+
pageSize,
|
|
514
|
+
totalPages: 0,
|
|
515
|
+
totalItems: 0,
|
|
516
|
+
hasNext: false,
|
|
517
|
+
hasPrev: false
|
|
518
|
+
},
|
|
519
|
+
tokenInfo: createTokenInfo('', maxTokens)
|
|
477
520
|
};
|
|
478
521
|
}
|
|
522
|
+
// Build and apply filters with progressive relaxation
|
|
523
|
+
const activeFilters = buildActiveFilters(params);
|
|
524
|
+
const { filtered: filteredResults, relaxation: filterRelaxation } = applyFiltersWithFallback(allResults, activeFilters);
|
|
525
|
+
// Deduplicate cross-version results when no version filter
|
|
526
|
+
const dedupedResults = params.version === undefined || params.version === ''
|
|
527
|
+
? deduplicateByLatestVersion(filteredResults)
|
|
528
|
+
: filteredResults;
|
|
529
|
+
// Calculate pagination
|
|
530
|
+
const paginationInfo = calculatePagination(dedupedResults.length, page, pageSize);
|
|
531
|
+
// Get paginated results
|
|
532
|
+
const paginatedResults = dedupedResults
|
|
533
|
+
.slice(paginationInfo.startIndex, paginationInfo.endIndex)
|
|
534
|
+
.map(r => r.result);
|
|
535
|
+
// Truncate results to fit within token budget
|
|
536
|
+
const { finalResults, truncated, truncatedContent } = truncateSearchResults(paginatedResults, maxTokens);
|
|
537
|
+
const finalTokenCount = estimateTokens(finalResults.map(r => `${r.title}\n${r.snippet}\n${r.url}`).join('\n\n'));
|
|
479
538
|
// Generate versionNote only when version was requested but not found in some results
|
|
480
539
|
const requestedVersion = params.version;
|
|
481
540
|
const hasVersionMismatch = requestedVersion !== undefined
|
|
482
541
|
&& requestedVersion !== 'current'
|
|
483
542
|
&& requestedVersion !== ''
|
|
484
|
-
&&
|
|
543
|
+
&& dedupedResults.some(r => !r.versionMatched);
|
|
485
544
|
const versionNote = hasVersionMismatch
|
|
486
545
|
? `Version "${requestedVersion}" was not available for some results. Showing the latest version instead.`
|
|
487
546
|
: undefined;
|
|
@@ -491,8 +550,8 @@ export async function searchDocumentation(params) {
|
|
|
491
550
|
page: paginationInfo.page,
|
|
492
551
|
pageSize: paginationInfo.pageSize,
|
|
493
552
|
totalPages: paginationInfo.totalPages,
|
|
494
|
-
totalItems:
|
|
495
|
-
hasNext: paginationInfo.hasNext
|
|
553
|
+
totalItems: dedupedResults.length,
|
|
554
|
+
hasNext: paginationInfo.hasNext,
|
|
496
555
|
hasPrev: paginationInfo.hasPrev
|
|
497
556
|
},
|
|
498
557
|
tokenInfo: {
|
|
@@ -511,10 +570,12 @@ export async function searchDocumentation(params) {
|
|
|
511
570
|
*/
|
|
512
571
|
export async function fetchArticle(url, options = {}) {
|
|
513
572
|
const maxTokens = options.maxTokens ?? TOKEN_CONFIG.DEFAULT_MAX_TOKENS;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const
|
|
573
|
+
// Infer locale from URL if not explicitly provided (e.g., /ja-JP/bundle/...)
|
|
574
|
+
const locale = options.locale ?? extractLocaleFromUrl(url);
|
|
575
|
+
// Strip locale prefix before transforming — backend doesn't accept /en-US/ etc. in path
|
|
576
|
+
const cleanUrl = stripLocalePrefix(url);
|
|
577
|
+
const displayUrl = transformToFrontendUrl(cleanUrl);
|
|
578
|
+
const articleFetchUrl = transformToBackendUrl(cleanUrl);
|
|
518
579
|
const cacheKey = `${locale}:article:${displayUrl}`;
|
|
519
580
|
// Check cache for raw article (without section/token processing)
|
|
520
581
|
let rawArticle = await cache.get(cacheKey);
|
|
@@ -539,10 +600,25 @@ export async function fetchArticle(url, options = {}) {
|
|
|
539
600
|
.filter(Boolean);
|
|
540
601
|
// Extract related articles
|
|
541
602
|
const relatedArticles = options.includeRelated === true
|
|
542
|
-
? $(SELECTORS.RELATED).map((_, el) =>
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
603
|
+
? $(SELECTORS.RELATED).map((_, el) => {
|
|
604
|
+
const rawHref = $(el).attr('href') ?? '';
|
|
605
|
+
// Skip empty or hash-only URLs (same-page anchors)
|
|
606
|
+
if (rawHref === '' || rawHref.startsWith('#')) {
|
|
607
|
+
return { title: '', url: '' };
|
|
608
|
+
}
|
|
609
|
+
// Resolve relative URLs against the article's fetch URL
|
|
610
|
+
let resolvedUrl;
|
|
611
|
+
try {
|
|
612
|
+
resolvedUrl = new URL(rawHref, articleFetchUrl).toString();
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
resolvedUrl = rawHref;
|
|
616
|
+
}
|
|
617
|
+
return {
|
|
618
|
+
title: $(el).text().trim(),
|
|
619
|
+
url: transformToFrontendUrl(resolvedUrl)
|
|
620
|
+
};
|
|
621
|
+
}).get().filter(r => r.title !== '' && r.url !== '')
|
|
546
622
|
: undefined;
|
|
547
623
|
// Extract product info from URL
|
|
548
624
|
const { product, version } = extractProductInfo(displayUrl);
|
|
@@ -629,7 +705,7 @@ async function discoverLatestBundleId(product) {
|
|
|
629
705
|
}
|
|
630
706
|
}
|
|
631
707
|
catch (error) {
|
|
632
|
-
|
|
708
|
+
log.error(`Error discovering bundle version for ${product}: ${String(error)}`);
|
|
633
709
|
}
|
|
634
710
|
return null;
|
|
635
711
|
}
|
|
@@ -722,12 +798,12 @@ async function buildTocFromSearch(product, locale) {
|
|
|
722
798
|
entries.push({ title, url });
|
|
723
799
|
}
|
|
724
800
|
if (entries.length > 0) {
|
|
725
|
-
|
|
801
|
+
log.info(`Built fallback TOC with ${entries.length} entries from search for ${product}`);
|
|
726
802
|
}
|
|
727
803
|
return entries;
|
|
728
804
|
}
|
|
729
805
|
catch (error) {
|
|
730
|
-
|
|
806
|
+
log.error(`Failed to build TOC from search for ${product}: ${String(error)}`);
|
|
731
807
|
return [];
|
|
732
808
|
}
|
|
733
809
|
}
|
|
@@ -752,7 +828,7 @@ export async function fetchTableOfContents(product, version = 'current', options
|
|
|
752
828
|
}
|
|
753
829
|
// Fetch TOC from backend (may 404 for some products)
|
|
754
830
|
const tocUrl = `${DOCS_API_URL}/bundle/${bundleId}/toc`;
|
|
755
|
-
|
|
831
|
+
log.debug(`Fetching TOC from: ${tocUrl}, Locale: ${locale}`);
|
|
756
832
|
allToc = [];
|
|
757
833
|
try {
|
|
758
834
|
const tocJson = await fetchJson(tocUrl, locale);
|
|
@@ -766,11 +842,11 @@ export async function fetchTableOfContents(product, version = 'current', options
|
|
|
766
842
|
}
|
|
767
843
|
catch (error) {
|
|
768
844
|
// TOC endpoint may 404 or fail for some products — fall through to search fallback
|
|
769
|
-
|
|
845
|
+
log.error(`Backend TOC request failed for ${product}: ${error instanceof Error ? error.message : String(error)}`);
|
|
770
846
|
}
|
|
771
847
|
// Fallback: build TOC from search results when backend returns empty or fails
|
|
772
848
|
if (allToc.length === 0) {
|
|
773
|
-
|
|
849
|
+
log.info(`No TOC entries for ${product}, falling back to search-based discovery`);
|
|
774
850
|
allToc = await buildTocFromSearch(product, locale);
|
|
775
851
|
}
|
|
776
852
|
// Cache result
|
|
@@ -812,7 +888,7 @@ export async function fetchTableOfContents(product, version = 'current', options
|
|
|
812
888
|
pageSize: paginationInfo.pageSize,
|
|
813
889
|
totalPages: paginationInfo.totalPages,
|
|
814
890
|
totalItems,
|
|
815
|
-
hasNext: paginationInfo.hasNext
|
|
891
|
+
hasNext: paginationInfo.hasNext,
|
|
816
892
|
hasPrev: paginationInfo.hasPrev
|
|
817
893
|
},
|
|
818
894
|
tokenInfo: {
|