@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.
- package/README.md +37 -2
- package/dist/core/constants/doc-types.d.ts +48 -0
- package/dist/core/constants/doc-types.d.ts.map +1 -0
- package/dist/core/constants/doc-types.js +58 -0
- package/dist/core/constants/doc-types.js.map +1 -0
- package/dist/core/constants/index.d.ts +16 -0
- package/dist/core/constants/index.d.ts.map +1 -0
- package/dist/core/constants/index.js +12 -0
- package/dist/core/constants/index.js.map +1 -0
- package/dist/core/constants/limits.d.ts +39 -0
- package/dist/core/constants/limits.d.ts.map +1 -0
- package/dist/core/constants/limits.js +51 -0
- package/dist/core/constants/limits.js.map +1 -0
- package/dist/core/constants/locales.d.ts +34 -0
- package/dist/core/constants/locales.d.ts.map +1 -0
- package/dist/core/constants/locales.js +20 -0
- package/dist/core/constants/locales.js.map +1 -0
- package/dist/core/constants/products.d.ts +127 -0
- package/dist/core/constants/products.d.ts.map +1 -0
- package/dist/core/constants/products.js +135 -0
- package/dist/core/constants/products.js.map +1 -0
- package/dist/core/constants/topics.d.ts +168 -0
- package/dist/core/constants/topics.d.ts.map +1 -0
- package/dist/core/constants/topics.js +186 -0
- package/dist/core/constants/topics.js.map +1 -0
- package/dist/core/constants.d.ts +7 -404
- package/dist/core/constants.d.ts.map +1 -1
- package/dist/core/constants.js +16 -427
- package/dist/core/constants.js.map +1 -1
- package/dist/core/http-client.d.ts +14 -2
- package/dist/core/http-client.d.ts.map +1 -1
- package/dist/core/http-client.js +148 -17
- package/dist/core/http-client.js.map +1 -1
- package/dist/core/index.d.ts +12 -12
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +12 -13
- package/dist/core/index.js.map +1 -1
- package/dist/core/resources/index.js +1 -1
- package/dist/core/resources/index.js.map +1 -1
- package/dist/core/schemas/index.d.ts +3 -1
- package/dist/core/schemas/index.d.ts.map +1 -1
- package/dist/core/schemas/index.js +13 -3
- package/dist/core/schemas/index.js.map +1 -1
- package/dist/core/schemas/output.d.ts +7 -0
- package/dist/core/schemas/output.d.ts.map +1 -1
- package/dist/core/schemas/output.js +7 -0
- package/dist/core/schemas/output.js.map +1 -1
- package/dist/core/services/article-service.d.ts +24 -0
- package/dist/core/services/article-service.d.ts.map +1 -0
- package/dist/core/services/article-service.js +140 -0
- package/dist/core/services/article-service.js.map +1 -0
- package/dist/core/services/content-parser.d.ts +38 -0
- package/dist/core/services/content-parser.d.ts.map +1 -0
- package/dist/core/services/content-parser.js +148 -0
- package/dist/core/services/content-parser.js.map +1 -0
- package/dist/core/services/ft-client.d.ts +52 -0
- package/dist/core/services/ft-client.d.ts.map +1 -0
- package/dist/core/services/ft-client.js +87 -0
- package/dist/core/services/ft-client.js.map +1 -0
- package/dist/core/services/glossary.d.ts +9 -5
- package/dist/core/services/glossary.d.ts.map +1 -1
- package/dist/core/services/glossary.js +175 -108
- package/dist/core/services/glossary.js.map +1 -1
- package/dist/core/services/interfaces/cache.d.ts +6 -6
- package/dist/core/services/interfaces/cache.d.ts.map +1 -1
- package/dist/core/services/interfaces/index.d.ts +3 -3
- package/dist/core/services/interfaces/index.d.ts.map +1 -1
- package/dist/core/services/interfaces/logger.d.ts +17 -10
- package/dist/core/services/interfaces/logger.d.ts.map +1 -1
- package/dist/core/services/interfaces/metadata.d.ts +0 -10
- package/dist/core/services/interfaces/metadata.d.ts.map +1 -1
- package/dist/core/services/interfaces/providers.d.ts +23 -10
- package/dist/core/services/interfaces/providers.d.ts.map +1 -1
- package/dist/core/services/logging.d.ts +38 -18
- package/dist/core/services/logging.d.ts.map +1 -1
- package/dist/core/services/logging.js +78 -29
- package/dist/core/services/logging.js.map +1 -1
- package/dist/core/services/maps-registry.d.ts +82 -0
- package/dist/core/services/maps-registry.d.ts.map +1 -0
- package/dist/core/services/maps-registry.js +232 -0
- package/dist/core/services/maps-registry.js.map +1 -0
- package/dist/core/services/metadata.d.ts +18 -9
- package/dist/core/services/metadata.d.ts.map +1 -1
- package/dist/core/services/metadata.js +113 -254
- package/dist/core/services/metadata.js.map +1 -1
- package/dist/core/services/search-service.d.ts +31 -0
- package/dist/core/services/search-service.d.ts.map +1 -0
- package/dist/core/services/search-service.js +462 -0
- package/dist/core/services/search-service.js.map +1 -0
- package/dist/core/services/toc-service.d.ts +30 -0
- package/dist/core/services/toc-service.d.ts.map +1 -0
- package/dist/core/services/toc-service.js +107 -0
- package/dist/core/services/toc-service.js.map +1 -0
- package/dist/core/services/tokenizer.d.ts +20 -1
- package/dist/core/services/tokenizer.d.ts.map +1 -1
- package/dist/core/services/tokenizer.js +32 -4
- package/dist/core/services/tokenizer.js.map +1 -1
- package/dist/core/services/topic-resolver.d.ts +74 -0
- package/dist/core/services/topic-resolver.d.ts.map +1 -0
- package/dist/core/services/topic-resolver.js +191 -0
- package/dist/core/services/topic-resolver.js.map +1 -0
- package/dist/core/tools/batch-get-articles.d.ts +0 -2
- package/dist/core/tools/batch-get-articles.d.ts.map +1 -1
- package/dist/core/tools/batch-get-articles.js +18 -46
- package/dist/core/tools/batch-get-articles.js.map +1 -1
- package/dist/core/tools/get-article.d.ts.map +1 -1
- package/dist/core/tools/get-article.js +29 -112
- package/dist/core/tools/get-article.js.map +1 -1
- package/dist/core/tools/get-toc.js +1 -1
- package/dist/core/tools/get-toc.js.map +1 -1
- package/dist/core/tools/glossary-lookup.d.ts.map +1 -1
- package/dist/core/tools/glossary-lookup.js +26 -7
- package/dist/core/tools/glossary-lookup.js.map +1 -1
- package/dist/core/tools/list-products.d.ts.map +1 -1
- package/dist/core/tools/list-products.js +5 -3
- package/dist/core/tools/list-products.js.map +1 -1
- package/dist/core/tools/search.js +5 -5
- package/dist/core/tools/search.js.map +1 -1
- package/dist/core/types/context.d.ts +5 -2
- package/dist/core/types/context.d.ts.map +1 -1
- package/dist/core/types.d.ts +141 -31
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/utils/bundle.d.ts +14 -1
- package/dist/core/utils/bundle.d.ts.map +1 -1
- package/dist/core/utils/bundle.js +19 -2
- package/dist/core/utils/bundle.js.map +1 -1
- package/dist/core/utils/format-article.d.ts +49 -0
- package/dist/core/utils/format-article.d.ts.map +1 -0
- package/dist/core/utils/format-article.js +188 -0
- package/dist/core/utils/format-article.js.map +1 -0
- package/dist/core/utils/ft-metadata.d.ts +22 -0
- package/dist/core/utils/ft-metadata.d.ts.map +1 -0
- package/dist/core/utils/ft-metadata.js +30 -0
- package/dist/core/utils/ft-metadata.js.map +1 -0
- package/dist/core/utils/sanitize.d.ts.map +1 -1
- package/dist/core/utils/sanitize.js +0 -2
- package/dist/core/utils/sanitize.js.map +1 -1
- package/dist/core/utils/url.d.ts.map +1 -1
- package/dist/core/utils/url.js +0 -1
- package/dist/core/utils/url.js.map +1 -1
- package/dist/index.js +10 -6
- package/dist/index.js.map +1 -1
- package/dist/platforms/node/http-server.d.ts +13 -0
- package/dist/platforms/node/http-server.d.ts.map +1 -0
- package/dist/platforms/node/http-server.js +231 -0
- package/dist/platforms/node/http-server.js.map +1 -0
- package/dist/platforms/node/index.d.ts +1 -1
- package/dist/platforms/node/index.d.ts.map +1 -1
- package/dist/platforms/node/index.js +1 -1
- package/dist/platforms/node/index.js.map +1 -1
- package/dist/platforms/node/logger.d.ts +13 -2
- package/dist/platforms/node/logger.d.ts.map +1 -1
- package/dist/platforms/node/logger.js +17 -4
- package/dist/platforms/node/logger.js.map +1 -1
- package/dist/transport/http-handler.d.ts +25 -0
- package/dist/transport/http-handler.d.ts.map +1 -0
- package/dist/transport/http-handler.js +193 -0
- package/dist/transport/http-handler.js.map +1 -0
- package/dist/transport/http-types.d.ts +22 -0
- package/dist/transport/http-types.d.ts.map +1 -0
- package/dist/transport/http-types.js +65 -0
- package/dist/transport/http-types.js.map +1 -0
- package/dist/transport/http.d.ts +4 -8
- package/dist/transport/http.d.ts.map +1 -1
- package/dist/transport/http.js +4 -376
- package/dist/transport/http.js.map +1 -1
- package/dist/transport/index.js +2 -2
- package/dist/transport/index.js.map +1 -1
- package/dist/transport/rate-limiter.d.ts +16 -0
- package/dist/transport/rate-limiter.d.ts.map +1 -0
- package/dist/transport/rate-limiter.js +55 -0
- package/dist/transport/rate-limiter.js.map +1 -0
- package/package.json +3 -3
- package/dist/core/services/interfaces.d.ts +0 -94
- package/dist/core/services/interfaces.d.ts.map +0 -1
- package/dist/core/services/interfaces.js +0 -8
- package/dist/core/services/interfaces.js.map +0 -1
- package/dist/core/services/scraper.d.ts +0 -90
- package/dist/core/services/scraper.d.ts.map +0 -1
- package/dist/core/services/scraper.js +0 -990
- package/dist/core/services/scraper.js.map +0 -1
- package/dist/core/utils/doc-type.d.ts +0 -12
- package/dist/core/utils/doc-type.d.ts.map +0 -1
- package/dist/core/utils/doc-type.js +0 -24
- package/dist/core/utils/doc-type.js.map +0 -1
- package/dist/platforms/node/metadata.d.ts +0 -18
- package/dist/platforms/node/metadata.d.ts.map +0 -1
- package/dist/platforms/node/metadata.js +0 -35
- 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
|
-
' ': ' ', '&': '&', '<': '<', '>': '>', '"': '"', ''': "'"
|
|
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
|