@enokdev/springdocs-mcp 1.2.8 → 1.4.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/README.md +83 -40
- package/build/config.d.ts +9 -0
- package/build/config.d.ts.map +1 -0
- package/build/config.js +32 -0
- package/build/config.js.map +1 -0
- package/build/format.d.ts +6 -0
- package/build/format.d.ts.map +1 -0
- package/build/format.js +23 -0
- package/build/format.js.map +1 -0
- package/build/http-server.d.ts +15 -0
- package/build/http-server.d.ts.map +1 -0
- package/build/http-server.js +131 -0
- package/build/http-server.js.map +1 -0
- package/build/index.js +182 -68
- package/build/index.js.map +1 -1
- package/build/prompts.d.ts +23 -0
- package/build/prompts.d.ts.map +1 -0
- package/build/prompts.js +112 -0
- package/build/prompts.js.map +1 -0
- package/build/resources.d.ts +31 -0
- package/build/resources.d.ts.map +1 -0
- package/build/resources.js +66 -0
- package/build/resources.js.map +1 -0
- package/build/services/advanced-features.d.ts +14 -9
- package/build/services/advanced-features.d.ts.map +1 -1
- package/build/services/advanced-features.js +205 -195
- package/build/services/advanced-features.js.map +1 -1
- package/build/services/boot-wiki.d.ts +25 -0
- package/build/services/boot-wiki.d.ts.map +1 -0
- package/build/services/boot-wiki.js +103 -0
- package/build/services/boot-wiki.js.map +1 -0
- package/build/services/cache.d.ts +18 -3
- package/build/services/cache.d.ts.map +1 -1
- package/build/services/cache.js +84 -3
- package/build/services/cache.js.map +1 -1
- package/build/services/dependency-finder.d.ts +46 -0
- package/build/services/dependency-finder.d.ts.map +1 -0
- package/build/services/dependency-finder.js +181 -0
- package/build/services/dependency-finder.js.map +1 -0
- package/build/services/diagnosis.d.ts +46 -0
- package/build/services/diagnosis.d.ts.map +1 -0
- package/build/services/diagnosis.js +481 -0
- package/build/services/diagnosis.js.map +1 -0
- package/build/services/guides-list.d.ts +16 -0
- package/build/services/guides-list.d.ts.map +1 -0
- package/build/services/guides-list.js +45 -0
- package/build/services/guides-list.js.map +1 -0
- package/build/services/http.d.ts +21 -0
- package/build/services/http.d.ts.map +1 -0
- package/build/services/http.js +168 -0
- package/build/services/http.js.map +1 -0
- package/build/services/initializr.d.ts +41 -0
- package/build/services/initializr.d.ts.map +1 -0
- package/build/services/initializr.js +135 -0
- package/build/services/initializr.js.map +1 -0
- package/build/services/markdown.d.ts +56 -0
- package/build/services/markdown.d.ts.map +1 -0
- package/build/services/markdown.js +146 -0
- package/build/services/markdown.js.map +1 -0
- package/build/services/migration-sources.d.ts +13 -0
- package/build/services/migration-sources.d.ts.map +1 -0
- package/build/services/migration-sources.js +48 -0
- package/build/services/migration-sources.js.map +1 -0
- package/build/services/release-notes.d.ts +23 -0
- package/build/services/release-notes.d.ts.map +1 -0
- package/build/services/release-notes.js +94 -0
- package/build/services/release-notes.js.map +1 -0
- package/build/services/search-index.d.ts +33 -0
- package/build/services/search-index.d.ts.map +1 -0
- package/build/services/search-index.js +147 -0
- package/build/services/search-index.js.map +1 -0
- package/build/services/spring-projects-config.d.ts +26 -33
- package/build/services/spring-projects-config.d.ts.map +1 -1
- package/build/services/spring-projects-config.js +248 -79
- package/build/services/spring-projects-config.js.map +1 -1
- package/build/services/springboot-docs-optimized.d.ts +57 -16
- package/build/services/springboot-docs-optimized.d.ts.map +1 -1
- package/build/services/springboot-docs-optimized.js +315 -197
- package/build/services/springboot-docs-optimized.js.map +1 -1
- package/build/services/url.d.ts +15 -0
- package/build/services/url.d.ts.map +1 -0
- package/build/services/url.js +40 -0
- package/build/services/url.js.map +1 -0
- package/build/tools/index.d.ts +376 -2
- package/build/tools/index.d.ts.map +1 -1
- package/build/tools/index.js +178 -11
- package/build/tools/index.js.map +1 -1
- package/build/validation.d.ts +7 -0
- package/build/validation.d.ts.map +1 -0
- package/build/validation.js +50 -0
- package/build/validation.js.map +1 -0
- package/build/version.d.ts +3 -0
- package/build/version.d.ts.map +1 -0
- package/build/version.js +6 -0
- package/build/version.js.map +1 -0
- package/package.json +6 -6
- package/build/debug.d.ts +0 -3
- package/build/debug.d.ts.map +0 -1
- package/build/debug.js +0 -292
- package/build/debug.js.map +0 -1
- package/build/services/advanced-features-old.d.ts +0 -45
- package/build/services/advanced-features-old.d.ts.map +0 -1
- package/build/services/advanced-features-old.js +0 -792
- package/build/services/advanced-features-old.js.map +0 -1
- package/build/services/springboot-docs-optimized-old.d.ts +0 -55
- package/build/services/springboot-docs-optimized-old.d.ts.map +0 -1
- package/build/services/springboot-docs-optimized-old.js +0 -588
- package/build/services/springboot-docs-optimized-old.js.map +0 -1
- package/build/services/springboot-docs.d.ts +0 -65
- package/build/services/springboot-docs.d.ts.map +0 -1
- package/build/services/springboot-docs.js +0 -708
- package/build/services/springboot-docs.js.map +0 -1
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import fetch from 'node-fetch';
|
|
2
1
|
import * as cheerio from 'cheerio';
|
|
3
|
-
import TurndownService from 'turndown';
|
|
4
2
|
import { CacheService } from './cache.js';
|
|
3
|
+
import { fetchWithRetry } from './http.js';
|
|
4
|
+
import { turndownService, extractContent, pageMarkdown } from './markdown.js';
|
|
5
|
+
import { absoluteSpringUrl, assertSafeSegment, normalizeVersion } from './url.js';
|
|
6
|
+
import { fetchSpringGuidesList } from './guides-list.js';
|
|
7
|
+
import { resolveWikiDocument, wikiPageName, wikiPageUrl, expectedWikiTitle, extractWikiMarkdown, selectSections } from './boot-wiki.js';
|
|
5
8
|
import { springProjectsConfig } from './spring-projects-config.js';
|
|
9
|
+
import { SearchIndex } from './search-index.js';
|
|
10
|
+
import { assertRawMarkdown, isMigrationProject, projectDisplayName, rawWikiTitle, rawWikiUrl, resolveRawDocument } from './migration-sources.js';
|
|
11
|
+
/** Below this size an extracted guide is considered empty (a real guide is far longer; the smallest fixture is ~190). */
|
|
12
|
+
const MIN_GUIDE_CHARS = 100;
|
|
6
13
|
/**
|
|
7
14
|
* Spring Documentation Service - Supports multiple Spring projects (Boot, AI, Framework, etc.)
|
|
8
15
|
* Uses ONLY real Spring documentation APIs - no mock data
|
|
@@ -14,78 +21,26 @@ export class SpringBootDocsServiceOptimized {
|
|
|
14
21
|
springProjectsUrl = 'https://spring.io/projects';
|
|
15
22
|
springGuideUrl = 'https://spring.io/guides';
|
|
16
23
|
projectsConfig;
|
|
17
|
-
turndownService;
|
|
18
24
|
cache;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
constructor(projectsConfig = springProjectsConfig) {
|
|
25
|
+
searchIndex;
|
|
26
|
+
constructor(projectsConfig = springProjectsConfig, cache = new CacheService(), searchIndex = new SearchIndex()) {
|
|
22
27
|
this.projectsConfig = projectsConfig;
|
|
23
|
-
this.
|
|
24
|
-
|
|
25
|
-
codeBlockStyle: 'fenced',
|
|
26
|
-
});
|
|
27
|
-
this.cache = new CacheService();
|
|
28
|
-
// Cleanup cache every hour
|
|
29
|
-
setInterval(() => this.cache.cleanup(), 60 * 60 * 1000);
|
|
28
|
+
this.cache = cache;
|
|
29
|
+
this.searchIndex = searchIndex;
|
|
30
30
|
}
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
extractIntelligentContent(markdown, detailLevel = 'medium') {
|
|
36
|
-
const limits = {
|
|
37
|
-
'summary': 1500,
|
|
38
|
-
'medium': 4000,
|
|
39
|
-
'full': 8000
|
|
40
|
-
};
|
|
41
|
-
const maxLength = limits[detailLevel] || limits['medium'];
|
|
42
|
-
// If content is smaller than limit, return as-is
|
|
43
|
-
if (markdown.length <= maxLength) {
|
|
44
|
-
return markdown;
|
|
45
|
-
}
|
|
46
|
-
// Extract important sections
|
|
47
|
-
const lines = markdown.split('\n');
|
|
48
|
-
let result = '';
|
|
49
|
-
let inCodeBlock = false;
|
|
50
|
-
let codeBlockContent = '';
|
|
51
|
-
let currentLength = 0;
|
|
52
|
-
for (const line of lines) {
|
|
53
|
-
// Track code blocks
|
|
54
|
-
if (line.trim().startsWith('```')) {
|
|
55
|
-
inCodeBlock = !inCodeBlock;
|
|
56
|
-
if (!inCodeBlock && codeBlockContent) {
|
|
57
|
-
// Keep complete code blocks
|
|
58
|
-
const blockToAdd = codeBlockContent + line + '\n';
|
|
59
|
-
if (currentLength + blockToAdd.length <= maxLength * 0.8) { // Reserve 20% for text
|
|
60
|
-
result += blockToAdd;
|
|
61
|
-
currentLength += blockToAdd.length;
|
|
62
|
-
}
|
|
63
|
-
codeBlockContent = '';
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
codeBlockContent = line + '\n';
|
|
67
|
-
}
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
if (inCodeBlock) {
|
|
71
|
-
codeBlockContent += line + '\n';
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
// Keep headers, important lines
|
|
75
|
-
if (line.startsWith('#') || line.startsWith('-') || line.startsWith('*') || line.trim().startsWith('>')) {
|
|
76
|
-
if (currentLength + line.length + 1 <= maxLength) {
|
|
77
|
-
result += line + '\n';
|
|
78
|
-
currentLength += line.length + 1;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
else if (line.trim() && currentLength + line.length + 1 <= maxLength) {
|
|
82
|
-
result += line + '\n';
|
|
83
|
-
currentLength += line.length + 1;
|
|
84
|
-
}
|
|
85
|
-
if (currentLength >= maxLength)
|
|
86
|
-
break;
|
|
31
|
+
/** Feeds the full-text index with a page already fetched; never breaks the read path. */
|
|
32
|
+
indexPage(docId, title, url, text) {
|
|
33
|
+
try {
|
|
34
|
+
this.searchIndex.add(docId, { title, url, text });
|
|
87
35
|
}
|
|
88
|
-
|
|
36
|
+
catch (error) {
|
|
37
|
+
console.error(`Content index failure for ${docId}:`, error instanceof Error ? error.message : error);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Re-feeds the index from the cache when a page was evicted or read before the index existed. */
|
|
41
|
+
reindexIfMissing(docId, title, url, text) {
|
|
42
|
+
if (!this.searchIndex.has(docId))
|
|
43
|
+
this.indexPage(docId, title, url, text);
|
|
89
44
|
}
|
|
90
45
|
/**
|
|
91
46
|
* Search Spring projects with caching and retry logic - REAL API ONLY
|
|
@@ -111,14 +66,14 @@ export class SpringBootDocsServiceOptimized {
|
|
|
111
66
|
const $project = $(element);
|
|
112
67
|
const title = $project.find('h2, h3, .title, .project-title').first().text().trim();
|
|
113
68
|
const description = $project.find('p, .description, .summary').first().text().trim();
|
|
114
|
-
const
|
|
115
|
-
if (title && (title.toLowerCase().includes(query.toLowerCase()) ||
|
|
69
|
+
const url = absoluteSpringUrl($project.find('a').first().attr('href'));
|
|
70
|
+
if (url && title && (title.toLowerCase().includes(query.toLowerCase()) ||
|
|
116
71
|
description.toLowerCase().includes(query.toLowerCase()))) {
|
|
117
72
|
projects.push({
|
|
118
73
|
type: 'spring-project',
|
|
119
74
|
title: title,
|
|
120
75
|
description: description,
|
|
121
|
-
url:
|
|
76
|
+
url: url,
|
|
122
77
|
});
|
|
123
78
|
}
|
|
124
79
|
});
|
|
@@ -128,22 +83,24 @@ export class SpringBootDocsServiceOptimized {
|
|
|
128
83
|
}
|
|
129
84
|
catch (error) {
|
|
130
85
|
console.error('Error searching Spring projects:', error);
|
|
131
|
-
|
|
86
|
+
throw error;
|
|
132
87
|
}
|
|
133
88
|
}
|
|
134
89
|
/**
|
|
135
|
-
* Get Spring project
|
|
90
|
+
* Get the full markdown of a Spring project page (cached) - REAL API ONLY
|
|
136
91
|
*/
|
|
137
|
-
async
|
|
92
|
+
async getProjectMarkdown(projectName) {
|
|
93
|
+
const slug = assertSafeSegment(projectName.toLowerCase().replace(/\s+/g, '-'), 'project name');
|
|
138
94
|
const cacheKey = `project:${projectName}`;
|
|
139
95
|
const cached = this.cache.get(cacheKey);
|
|
140
96
|
if (cached) {
|
|
141
97
|
console.error(`✅ Cache hit for project: ${projectName}`);
|
|
98
|
+
this.reindexIfMissing(`project:${slug}`, projectName, cached.url, cached.markdown);
|
|
142
99
|
return cached;
|
|
143
100
|
}
|
|
144
101
|
console.error(`🔍 Fetching project: ${projectName}`);
|
|
145
102
|
try {
|
|
146
|
-
const url = `${this.springProjectsUrl}/${
|
|
103
|
+
const url = `${this.springProjectsUrl}/${slug}`;
|
|
147
104
|
const response = await this.fetchWithRetry(url);
|
|
148
105
|
if (!response.ok) {
|
|
149
106
|
throw new Error(`Project not found: ${projectName}`);
|
|
@@ -154,17 +111,25 @@ export class SpringBootDocsServiceOptimized {
|
|
|
154
111
|
if (content.length === 0) {
|
|
155
112
|
throw new Error('No content found for project');
|
|
156
113
|
}
|
|
157
|
-
const markdown =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
114
|
+
const markdown = turndownService.turndown(content.html() || '');
|
|
115
|
+
// Cache the full markdown so later pages need no new fetch
|
|
116
|
+
this.indexPage(`project:${slug}`, projectName, url, markdown);
|
|
117
|
+
const entry = { markdown, url };
|
|
118
|
+
this.cache.setLongTerm(cacheKey, entry);
|
|
119
|
+
return entry;
|
|
162
120
|
}
|
|
163
121
|
catch (error) {
|
|
164
122
|
console.error(`Error fetching project ${projectName}:`, error);
|
|
165
123
|
throw error;
|
|
166
124
|
}
|
|
167
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Get Spring project details - REAL API ONLY
|
|
128
|
+
*/
|
|
129
|
+
async getSpringProject(projectName, offset = 0) {
|
|
130
|
+
const { markdown, url } = await this.getProjectMarkdown(projectName);
|
|
131
|
+
return this.formatPage(projectName, markdown, url, offset, 'For complete project info, visit');
|
|
132
|
+
}
|
|
168
133
|
/**
|
|
169
134
|
* Get all Spring guides - REAL API ONLY
|
|
170
135
|
*/
|
|
@@ -177,57 +142,45 @@ export class SpringBootDocsServiceOptimized {
|
|
|
177
142
|
}
|
|
178
143
|
console.error(`🔍 Fetching guides for category: ${category || 'all'}`);
|
|
179
144
|
try {
|
|
180
|
-
const
|
|
181
|
-
if (!response.ok) {
|
|
182
|
-
throw new Error('Unable to access Spring guides page');
|
|
183
|
-
}
|
|
184
|
-
const html = await response.text();
|
|
185
|
-
const $ = cheerio.load(html);
|
|
186
|
-
const guides = [];
|
|
187
|
-
// Parse actual Spring guides page
|
|
188
|
-
$('.guide-item, .card, .guide-card, .list-item, .guide').each((_, element) => {
|
|
189
|
-
const $guide = $(element);
|
|
190
|
-
const title = $guide.find('h2, h3, .title, .guide-title, a').first().text().trim();
|
|
191
|
-
const description = $guide.find('p, .description, .summary').first().text().trim();
|
|
192
|
-
const guideCategory = $guide.find('.category, .badge, .label').first().text().trim();
|
|
193
|
-
const link = $guide.find('a').first().attr('href');
|
|
194
|
-
if (title) {
|
|
195
|
-
if (!category || guideCategory.toLowerCase().includes(category.toLowerCase())) {
|
|
196
|
-
guides.push({
|
|
197
|
-
type: 'spring-guide',
|
|
198
|
-
title: title,
|
|
199
|
-
description: description || 'Spring guide',
|
|
200
|
-
category: guideCategory || 'General',
|
|
201
|
-
url: link?.startsWith('http') ? link : `https://spring.io${link}`,
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
});
|
|
145
|
+
const guides = await fetchSpringGuidesList(category);
|
|
206
146
|
const results = guides.slice(0, limit);
|
|
207
147
|
this.cache.set(cacheKey, results);
|
|
208
148
|
return results;
|
|
209
149
|
}
|
|
210
150
|
catch (error) {
|
|
211
151
|
console.error('Error retrieving Spring guides:', error);
|
|
212
|
-
|
|
152
|
+
throw error;
|
|
213
153
|
}
|
|
214
154
|
}
|
|
215
155
|
/**
|
|
216
156
|
* Get specific guide content - REAL API ONLY
|
|
217
157
|
*/
|
|
218
158
|
async getGuide(guideId, detailLevel = 'medium') {
|
|
219
|
-
|
|
159
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(guideId) || guideId.includes('..')) {
|
|
160
|
+
throw new Error(`Invalid guideId "${guideId}": only letters, digits, '.', '_' and '-' are allowed`);
|
|
161
|
+
}
|
|
162
|
+
// Getting-started guides live under /guides/gs/<name>/; accept the repository-style "gs-<name>" too
|
|
163
|
+
const name = guideId.replace(/^gs-/, '');
|
|
164
|
+
if (!name) {
|
|
165
|
+
throw new Error(`Invalid guideId "${guideId}": the guide name is empty`);
|
|
166
|
+
}
|
|
167
|
+
const safeId = encodeURIComponent(name);
|
|
168
|
+
const cacheKey = `guide:${name}:${detailLevel}`;
|
|
169
|
+
const rawKey = `guide-md:${name}`;
|
|
220
170
|
const cached = this.cache.get(cacheKey);
|
|
221
171
|
if (cached) {
|
|
222
172
|
console.error(`✅ Cache hit for guide: ${guideId} (${detailLevel})`);
|
|
173
|
+
// The formatted text depends on detailLevel: the index is fed from the full markdown kept apart
|
|
174
|
+
const full = this.cache.get(rawKey);
|
|
175
|
+
if (full)
|
|
176
|
+
this.reindexIfMissing(`guide:${name}`, `Guide: ${name}`, full.url, full.markdown);
|
|
223
177
|
return cached;
|
|
224
178
|
}
|
|
225
179
|
console.error(`🔍 Fetching guide: ${guideId} with detail level: ${detailLevel}`);
|
|
226
|
-
//
|
|
180
|
+
// Fallbacks tried in order: getting-started guide, then top-level guide path
|
|
227
181
|
const sources = [
|
|
228
|
-
{ name: 'Spring.io', url: `${this.springGuideUrl}/${
|
|
229
|
-
{ name: '
|
|
230
|
-
{ name: 'Spring.io alt', url: `${this.springGuideUrl}/gs/${guideId}/` }
|
|
182
|
+
{ name: 'Spring.io getting-started', url: `${this.springGuideUrl}/gs/${safeId}/` },
|
|
183
|
+
{ name: 'Spring.io', url: `${this.springGuideUrl}/${safeId}/` }
|
|
231
184
|
];
|
|
232
185
|
let content = '';
|
|
233
186
|
let sourceUrl = '';
|
|
@@ -253,7 +206,11 @@ export class SpringBootDocsServiceOptimized {
|
|
|
253
206
|
throw new Error(`Guide not found: ${guideId}`);
|
|
254
207
|
}
|
|
255
208
|
try {
|
|
256
|
-
const
|
|
209
|
+
const markdown = this.extractGuideMarkdown(content, sourceUrl);
|
|
210
|
+
const result = this.formatGuide(markdown, guideId, sourceUrl, detailLevel);
|
|
211
|
+
// Index the complete text, whatever the detail level of this read
|
|
212
|
+
this.indexPage(`guide:${name}`, `Guide: ${name}`, sourceUrl, markdown);
|
|
213
|
+
this.cache.setLongTerm(rawKey, { markdown, url: sourceUrl });
|
|
257
214
|
this.cache.setLongTerm(cacheKey, result);
|
|
258
215
|
return result;
|
|
259
216
|
}
|
|
@@ -277,14 +234,22 @@ export class SpringBootDocsServiceOptimized {
|
|
|
277
234
|
* @param projectId - Project identifier ('boot', 'ai', 'framework', etc.)
|
|
278
235
|
* @param section - Documentation section (e.g., 'web', 'chatclient', 'core')
|
|
279
236
|
* @param subsection - Optional subsection for deeper navigation
|
|
237
|
+
* @param offset - Character offset for paginated reads
|
|
238
|
+
* @param version - Optional documentation version ('3.4' or '3.4.2'; omitted/'current' = latest)
|
|
280
239
|
* @returns Formatted markdown documentation with source URL
|
|
281
240
|
*/
|
|
282
|
-
async getSpringReference(projectId, section, subsection) {
|
|
283
|
-
const
|
|
241
|
+
async getSpringReference(projectId, section, subsection, offset = 0, version) {
|
|
242
|
+
const safeSection = assertSafeSegment(section, 'section');
|
|
243
|
+
const safeSubsection = subsection ? assertSafeSegment(subsection, 'subsection') : undefined;
|
|
244
|
+
// Validate the version before any cache or network access
|
|
245
|
+
const normalizedVersion = normalizeVersion(version);
|
|
246
|
+
const baseKey = `reference:${projectId}:${section}:${subsection || 'main'}`;
|
|
247
|
+
const cacheKey = normalizedVersion ? `${baseKey}:v${normalizedVersion}` : baseKey;
|
|
284
248
|
const cached = this.cache.get(cacheKey);
|
|
285
249
|
if (cached) {
|
|
286
250
|
console.error(`✅ Cache hit for reference: ${projectId}/${section}`);
|
|
287
|
-
|
|
251
|
+
this.reindexIfMissing(`reference:${projectId}:${normalizedVersion ?? 'current'}:${section}:${subsection ?? 'main'}`, this.referenceTitle(projectId, section, subsection), cached.url, cached.markdown);
|
|
252
|
+
return this.formatPage(this.referenceTitle(projectId, section, subsection), cached.markdown, cached.url, offset, 'For complete reference, visit');
|
|
288
253
|
}
|
|
289
254
|
console.error(`🔍 Fetching reference: ${projectId}/${section}`);
|
|
290
255
|
try {
|
|
@@ -296,9 +261,12 @@ export class SpringBootDocsServiceOptimized {
|
|
|
296
261
|
throw new Error(`Invalid section "${section}" for ${project.displayName}. Available sections: ${availableSections}`);
|
|
297
262
|
}
|
|
298
263
|
// Build URL using configuration
|
|
299
|
-
const url = this.projectsConfig.buildReferenceUrl(projectId,
|
|
264
|
+
const url = this.projectsConfig.buildReferenceUrl(projectId, safeSection, safeSubsection, normalizedVersion);
|
|
300
265
|
const response = await this.fetchWithRetry(url);
|
|
301
266
|
if (!response.ok) {
|
|
267
|
+
if (normalizedVersion) {
|
|
268
|
+
throw new Error(`Reference not found for ${project.displayName} version ${normalizedVersion} (this version may not be published at the current documentation site; omit 'version' for the latest or try a more recent one)`);
|
|
269
|
+
}
|
|
302
270
|
throw new Error(`Reference section not found: ${project.displayName} / ${section}`);
|
|
303
271
|
}
|
|
304
272
|
const html = await response.text();
|
|
@@ -308,42 +276,146 @@ export class SpringBootDocsServiceOptimized {
|
|
|
308
276
|
if (content.length === 0) {
|
|
309
277
|
throw new Error(`No content found in ${project.displayName} reference documentation`);
|
|
310
278
|
}
|
|
311
|
-
const markdown =
|
|
312
|
-
const
|
|
279
|
+
const markdown = turndownService.turndown(content.html() || '');
|
|
280
|
+
const entry = { markdown, url };
|
|
281
|
+
this.indexPage(`reference:${projectId}:${normalizedVersion ?? 'current'}:${section}:${subsection ?? 'main'}`, this.referenceTitle(projectId, section, subsection), url, markdown);
|
|
313
282
|
// Use project-specific cache strategy
|
|
314
283
|
const cacheTTL = this.projectsConfig.getCacheTTL(projectId);
|
|
315
284
|
if (project.cacheStrategy === 'long') {
|
|
316
|
-
this.cache.setLongTerm(cacheKey,
|
|
285
|
+
this.cache.setLongTerm(cacheKey, entry);
|
|
317
286
|
}
|
|
318
287
|
else {
|
|
319
|
-
this.cache.set(cacheKey,
|
|
288
|
+
this.cache.set(cacheKey, entry, cacheTTL);
|
|
320
289
|
}
|
|
321
|
-
return
|
|
290
|
+
return this.formatPage(this.referenceTitle(projectId, section, subsection), markdown, url, offset, 'For complete reference, visit');
|
|
322
291
|
}
|
|
323
292
|
catch (error) {
|
|
324
293
|
console.error(`Error fetching reference ${projectId}/${section}:`, error);
|
|
325
294
|
throw error;
|
|
326
295
|
}
|
|
327
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Get the Spring Boot migration guide or upgrade release notes from the GitHub wiki
|
|
299
|
+
*
|
|
300
|
+
* @param version - Target version ('3.0', '3.4' or '3.4.2'; the patch is ignored)
|
|
301
|
+
* @param document - 'auto' (migration guide for x.0, release notes otherwise) or an explicit document
|
|
302
|
+
* @param section - Optional keyword; only matching headings (with sub-sections) are returned
|
|
303
|
+
* @param offset - Character offset for paginated reads
|
|
304
|
+
*/
|
|
305
|
+
async getMigrationGuide(version, document = 'auto', section, offset = 0, project = 'spring-boot') {
|
|
306
|
+
if (!isMigrationProject(project)) {
|
|
307
|
+
throw new Error(`Unknown project "${project}" for migration guides`);
|
|
308
|
+
}
|
|
309
|
+
// Resolve version and document before any cache or network access
|
|
310
|
+
const normalizedVersion = normalizeVersion(version);
|
|
311
|
+
if (!normalizedVersion) {
|
|
312
|
+
throw new Error(`A target ${projectDisplayName(project)} version is required (e.g. "3.0", "3.4" or "4.0")`);
|
|
313
|
+
}
|
|
314
|
+
const resolved = project === 'spring-boot'
|
|
315
|
+
? resolveWikiDocument(normalizedVersion, document)
|
|
316
|
+
: resolveRawDocument(project, document);
|
|
317
|
+
const keyword = section?.trim() || undefined;
|
|
318
|
+
const cacheKey = project === 'spring-boot'
|
|
319
|
+
? `migration:${resolved}:${normalizedVersion}`
|
|
320
|
+
: `migration:${project}:${resolved}:${normalizedVersion}`;
|
|
321
|
+
let entry = this.cache.get(cacheKey);
|
|
322
|
+
if (entry) {
|
|
323
|
+
console.error(`✅ Cache hit for migration page: ${cacheKey}`);
|
|
324
|
+
this.reindexIfMissing(cacheKey, entry.title, entry.url, entry.markdown);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
entry = project === 'spring-boot'
|
|
328
|
+
? await this.fetchBootWikiPage(normalizedVersion, resolved)
|
|
329
|
+
: await this.fetchRawWikiPage(project, normalizedVersion);
|
|
330
|
+
// Only successful, verified pages are cached and indexed (whole page, not just the requested section)
|
|
331
|
+
this.indexPage(cacheKey, entry.title, entry.url, entry.markdown);
|
|
332
|
+
this.cache.setLongTerm(cacheKey, entry);
|
|
333
|
+
}
|
|
334
|
+
const markdown = keyword ? selectSections(entry.markdown, keyword, entry.title) : entry.markdown;
|
|
335
|
+
const title = keyword ? `${entry.title} (section: ${keyword})` : entry.title;
|
|
336
|
+
return this.formatPage(title, markdown, entry.url, offset, 'For the complete page, visit', Boolean(keyword));
|
|
337
|
+
}
|
|
338
|
+
async fetchBootWikiPage(version, document) {
|
|
339
|
+
const url = wikiPageUrl(wikiPageName(version, document));
|
|
340
|
+
console.error(`🔍 Fetching migration page: ${url}`);
|
|
341
|
+
const response = await this.fetchWithRetry(url);
|
|
342
|
+
if (!response.ok) {
|
|
343
|
+
throw new Error(`Failed to fetch Spring Boot wiki page: ${response.status}`);
|
|
344
|
+
}
|
|
345
|
+
const html = await response.text();
|
|
346
|
+
const { markdown, title } = extractWikiMarkdown(html, expectedWikiTitle(version, document), url);
|
|
347
|
+
return { markdown, url, title };
|
|
348
|
+
}
|
|
349
|
+
/** Raw markdown wiki page (Framework, Batch): a missing page is a real 404, an HTML body is never a guide. */
|
|
350
|
+
async fetchRawWikiPage(project, version) {
|
|
351
|
+
const url = rawWikiUrl(project, version);
|
|
352
|
+
const title = rawWikiTitle(project, version);
|
|
353
|
+
console.error(`🔍 Fetching migration page: ${url}`);
|
|
354
|
+
const response = await this.fetchWithRetry(url);
|
|
355
|
+
if (response.status === 404) {
|
|
356
|
+
throw new Error(`Wiki page not found: ${title} (${url})`);
|
|
357
|
+
}
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
throw new Error(`Failed to fetch ${title}: ${response.status}`);
|
|
360
|
+
}
|
|
361
|
+
const markdown = assertRawMarkdown(await response.text(), title, url);
|
|
362
|
+
return { markdown, url, title };
|
|
363
|
+
}
|
|
364
|
+
referenceTitle(projectId, section, subsection) {
|
|
365
|
+
const project = this.projectsConfig.getProject(projectId);
|
|
366
|
+
return `${project.displayName} Reference: ${subsection ? `${section}/${subsection}` : section}`;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Format one page of a full markdown document with a pagination footer
|
|
370
|
+
*/
|
|
371
|
+
formatPage(title, markdown, url, offset, linkLabel, filtered = false) {
|
|
372
|
+
const page = pageMarkdown(markdown, offset);
|
|
373
|
+
if (offset >= page.total) {
|
|
374
|
+
return `No content at offset ${offset} (total: ${page.total} characters).`;
|
|
375
|
+
}
|
|
376
|
+
let result = `# ${title}\n\n${page.content}`;
|
|
377
|
+
if (page.nextOffset !== null) {
|
|
378
|
+
const note = filtered ? ' The offset applies to the filtered text (the selected section), not to the full page.' : '';
|
|
379
|
+
result += `\n\n---\nPart ${page.start}–${page.end} of ${page.total} characters. To continue, call again with offset=${page.nextOffset}.${note}`;
|
|
380
|
+
}
|
|
381
|
+
return `${result}\n\n${linkLabel}: ${url}`;
|
|
382
|
+
}
|
|
328
383
|
/**
|
|
329
384
|
* Search Spring concepts - alias for backward compatibility
|
|
330
385
|
*/
|
|
331
|
-
async searchConcepts(concept,
|
|
332
|
-
return this.searchSpringConcepts(concept,
|
|
386
|
+
async searchConcepts(concept, version) {
|
|
387
|
+
return this.searchSpringConcepts(concept, version);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Entry page of the Spring Boot reference documentation: the current docs (legacy URL, unchanged)
|
|
391
|
+
* or the page of a published version ("3.4" or "3.4.2"; the patch is ignored).
|
|
392
|
+
*/
|
|
393
|
+
bootReferenceIndexUrl(normalizedVersion) {
|
|
394
|
+
return normalizedVersion
|
|
395
|
+
? `${this.baseUrl}/spring-boot/${normalizedVersion}/reference/index.html`
|
|
396
|
+
: `${this.baseUrl}/spring-boot/docs/current/reference/html/`;
|
|
397
|
+
}
|
|
398
|
+
bootVersionNotFound(normalizedVersion) {
|
|
399
|
+
return new Error(`Spring Boot documentation for version ${normalizedVersion} not found (this version may not be published at the current documentation site; omit 'version' for the latest or try a more recent one)`);
|
|
333
400
|
}
|
|
334
401
|
/**
|
|
335
402
|
* Search Spring concepts - using real documentation
|
|
403
|
+
*
|
|
404
|
+
* @param version - Optional Spring Boot documentation version ('3.4' or '3.4.2'; omitted/'current' = latest)
|
|
336
405
|
*/
|
|
337
|
-
async searchSpringConcepts(concept,
|
|
338
|
-
const
|
|
406
|
+
async searchSpringConcepts(concept, version) {
|
|
407
|
+
const normalizedVersion = normalizeVersion(version);
|
|
408
|
+
const cacheKey = normalizedVersion ? `concepts:${concept}:v${normalizedVersion}` : `concepts:${concept}`;
|
|
339
409
|
const cached = this.cache.get(cacheKey);
|
|
340
410
|
if (cached)
|
|
341
411
|
return cached;
|
|
342
412
|
try {
|
|
343
413
|
// Search in Spring Boot reference documentation
|
|
344
|
-
const searchUrl =
|
|
414
|
+
const searchUrl = this.bootReferenceIndexUrl(normalizedVersion);
|
|
345
415
|
const response = await this.fetchWithRetry(searchUrl);
|
|
346
416
|
if (!response.ok) {
|
|
417
|
+
if (normalizedVersion)
|
|
418
|
+
throw this.bootVersionNotFound(normalizedVersion);
|
|
347
419
|
throw new Error('Unable to access Spring Boot documentation');
|
|
348
420
|
}
|
|
349
421
|
const html = await response.text();
|
|
@@ -359,7 +431,7 @@ export class SpringBootDocsServiceOptimized {
|
|
|
359
431
|
const headingText = heading.text().toLowerCase();
|
|
360
432
|
if (headingText.includes(concept.toLowerCase())) {
|
|
361
433
|
const section = heading.parent();
|
|
362
|
-
const sectionMarkdown =
|
|
434
|
+
const sectionMarkdown = turndownService.turndown(section.html() || '');
|
|
363
435
|
conceptContent += sectionMarkdown.substring(0, 500) + '\n\n';
|
|
364
436
|
foundSections++;
|
|
365
437
|
}
|
|
@@ -375,53 +447,110 @@ export class SpringBootDocsServiceOptimized {
|
|
|
375
447
|
}
|
|
376
448
|
catch (error) {
|
|
377
449
|
console.error('Error searching concepts:', error);
|
|
378
|
-
|
|
450
|
+
throw error;
|
|
379
451
|
}
|
|
380
452
|
}
|
|
381
453
|
/**
|
|
382
454
|
* Search documentation with real API - alias for backward compatibility
|
|
383
455
|
*/
|
|
384
|
-
async searchDocumentation(query, docType = 'all', limit = 10) {
|
|
385
|
-
return this.searchSpringDocs(query, docType, limit);
|
|
456
|
+
async searchDocumentation(query, docType = 'all', limit = 10, version) {
|
|
457
|
+
return this.searchSpringDocs(query, docType, limit, version);
|
|
386
458
|
}
|
|
387
459
|
/**
|
|
388
460
|
* Search documentation with real API
|
|
461
|
+
*
|
|
462
|
+
* @param version - Optional Spring Boot documentation version for the Boot reference part
|
|
463
|
+
* ('3.4' or '3.4.2'; omitted/'current' = latest). Guides and projects are not versioned and ignore it.
|
|
389
464
|
*/
|
|
390
|
-
async searchSpringDocs(query, docType = 'all', limit = 10) {
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const
|
|
465
|
+
async searchSpringDocs(query, docType = 'all', limit = 10, version) {
|
|
466
|
+
const allowedDocTypes = ['guides', 'reference', 'projects', 'content', 'all'];
|
|
467
|
+
if (!allowedDocTypes.includes(docType)) {
|
|
468
|
+
throw new Error(`Invalid docType "${docType}". Allowed: ${allowedDocTypes.join(', ')}`);
|
|
469
|
+
}
|
|
470
|
+
const normalizedVersion = normalizeVersion(version);
|
|
471
|
+
const baseKey = `docs:${query}:${docType}:${limit}`;
|
|
472
|
+
const cacheKey = normalizedVersion ? `${baseKey}:v${normalizedVersion}` : baseKey;
|
|
473
|
+
// Only the title sources are cached: content hits depend on the current state of the index
|
|
474
|
+
const titles = this.cache.get(cacheKey) ?? await this.searchTitleSources(query, docType, limit, cacheKey, normalizedVersion);
|
|
475
|
+
if (docType !== 'all' && docType !== 'content') {
|
|
476
|
+
return titles.slice(0, limit);
|
|
477
|
+
}
|
|
478
|
+
return this.mergeContentResults(titles, query, limit, normalizedVersion);
|
|
479
|
+
}
|
|
480
|
+
mergeContentResults(titles, query, limit, normalizedVersion) {
|
|
481
|
+
const seen = new Set(titles.map(result => result.url));
|
|
482
|
+
let content = [];
|
|
396
483
|
try {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
484
|
+
// With a version, Boot reference pages of other versions are left out (each page is indexed
|
|
485
|
+
// under its own versioned docId); guides, projects and other projects' pages are not versioned
|
|
486
|
+
const sameVersion = (docId) => !normalizedVersion || !docId.startsWith('reference:boot:') || docId.startsWith(`reference:boot:${normalizedVersion}:`);
|
|
487
|
+
content = this.searchIndex.search(query, limit, sameVersion)
|
|
488
|
+
.filter(hit => !seen.has(hit.url))
|
|
489
|
+
.map(hit => ({ type: 'content', title: hit.title, url: hit.url, description: hit.snippet, score: hit.score }));
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
console.error('Content search failed:', error instanceof Error ? error.message : error);
|
|
493
|
+
}
|
|
494
|
+
// Content takes the slots titles leave free, and at most half of them otherwise
|
|
495
|
+
const contentSlots = Math.min(content.length, Math.max(Math.floor(limit / 2), limit - titles.length));
|
|
496
|
+
const merged = [...titles.slice(0, limit - contentSlots), ...content.slice(0, contentSlots)];
|
|
497
|
+
if (this.searchIndex.size === 0) {
|
|
498
|
+
merged.push({
|
|
499
|
+
type: 'note',
|
|
500
|
+
title: 'Content index is empty',
|
|
501
|
+
description: 'Full-text search covers pages already read: read a page first (get_spring_project, get_spring_reference or get_spring_guide), then search again.'
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return merged;
|
|
505
|
+
}
|
|
506
|
+
async searchTitleSources(query, docType, limit, cacheKey, normalizedVersion) {
|
|
507
|
+
const results = [];
|
|
508
|
+
const sources = [];
|
|
509
|
+
if (docType === 'all' || docType === 'guides') {
|
|
510
|
+
sources.push(['guides', async () => {
|
|
511
|
+
const guides = await this.getAllSpringGuides(undefined, Number.MAX_SAFE_INTEGER);
|
|
512
|
+
return guides.filter(guide => guide.title.toLowerCase().includes(query.toLowerCase()) ||
|
|
513
|
+
guide.description.toLowerCase().includes(query.toLowerCase())).slice(0, limit);
|
|
514
|
+
}]);
|
|
515
|
+
}
|
|
516
|
+
if (docType === 'all' || docType === 'projects') {
|
|
517
|
+
sources.push(['projects', () => this.searchSpringProjects(query, limit)]);
|
|
518
|
+
}
|
|
519
|
+
if (docType === 'all' || docType === 'reference') {
|
|
520
|
+
sources.push(['reference', () => this.searchInReference(query, limit, normalizedVersion)]);
|
|
521
|
+
}
|
|
522
|
+
// A failing source must not be hidden nor cached: fail if all fail, otherwise return partial results uncached
|
|
523
|
+
const failures = [];
|
|
524
|
+
const settled = await Promise.allSettled(sources.map(([, run]) => run()));
|
|
525
|
+
settled.forEach((outcome, index) => {
|
|
526
|
+
if (outcome.status === 'fulfilled') {
|
|
527
|
+
results.push(...outcome.value);
|
|
406
528
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
results.push(...refResults);
|
|
529
|
+
else {
|
|
530
|
+
failures.push(sources[index][0]);
|
|
531
|
+
console.error(`Documentation source "${sources[index][0]}" failed:`, outcome.reason instanceof Error ? outcome.reason.message : outcome.reason);
|
|
411
532
|
}
|
|
412
|
-
|
|
413
|
-
|
|
533
|
+
});
|
|
534
|
+
if (sources.length > 0 && failures.length === sources.length) {
|
|
535
|
+
const reasons = settled
|
|
536
|
+
.map(outcome => (outcome.status === 'rejected' ? (outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)) : ''))
|
|
537
|
+
.filter(Boolean);
|
|
538
|
+
throw new Error(`Unable to search documentation: all sources failed (${failures.join(', ')}): ${reasons.join('; ')}`);
|
|
414
539
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
return [];
|
|
540
|
+
if (failures.length === 0) {
|
|
541
|
+
this.cache.set(cacheKey, results);
|
|
418
542
|
}
|
|
543
|
+
return results.slice(0, limit);
|
|
419
544
|
}
|
|
420
|
-
async searchInReference(query, limit) {
|
|
545
|
+
async searchInReference(query, limit, normalizedVersion) {
|
|
421
546
|
try {
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
547
|
+
const pageUrl = this.bootReferenceIndexUrl(normalizedVersion);
|
|
548
|
+
const response = await this.fetchWithRetry(pageUrl);
|
|
549
|
+
if (!response.ok) {
|
|
550
|
+
if (normalizedVersion)
|
|
551
|
+
throw this.bootVersionNotFound(normalizedVersion);
|
|
552
|
+
throw new Error('Unable to access Spring Boot reference documentation');
|
|
553
|
+
}
|
|
425
554
|
const html = await response.text();
|
|
426
555
|
const $ = cheerio.load(html);
|
|
427
556
|
const results = [];
|
|
@@ -433,7 +562,9 @@ export class SpringBootDocsServiceOptimized {
|
|
|
433
562
|
results.push({
|
|
434
563
|
type: 'reference',
|
|
435
564
|
title: `Spring Boot: ${text}`,
|
|
436
|
-
url:
|
|
565
|
+
url: normalizedVersion
|
|
566
|
+
? new URL(href, pageUrl).href // nav hrefs of the versioned site are relative to the page
|
|
567
|
+
: href.startsWith('http') ? href : `${this.baseUrl}/spring-boot/docs/current/reference/html/${href}`,
|
|
437
568
|
description: `Reference documentation section`
|
|
438
569
|
});
|
|
439
570
|
}
|
|
@@ -442,55 +573,42 @@ export class SpringBootDocsServiceOptimized {
|
|
|
442
573
|
}
|
|
443
574
|
catch (error) {
|
|
444
575
|
console.error('Error searching reference:', error);
|
|
445
|
-
|
|
576
|
+
throw error;
|
|
446
577
|
}
|
|
447
578
|
}
|
|
448
|
-
|
|
449
|
-
console.
|
|
579
|
+
extractGuideMarkdown(content, sourceUrl) {
|
|
580
|
+
console.error('Processing HTML guide content...');
|
|
450
581
|
const $ = cheerio.load(content);
|
|
451
582
|
// Remove navigation and footer elements
|
|
452
583
|
$('nav, footer, .navbar, .sidebar, #js-sidebar').remove();
|
|
453
584
|
// Get main content
|
|
454
|
-
|
|
585
|
+
// Selectors are tried in priority order: a single comma list would pick the first match in
|
|
586
|
+
// document order (on spring.io an unrelated <article> card comes before the guide body)
|
|
587
|
+
const selectors = ['.ascii-doc', '.content', '.guide-content', 'main', '.markdown-body', '.guide-body', 'article'];
|
|
588
|
+
const mainContent = selectors.map(selector => $(selector).first()).find(match => match.length > 0) ?? $();
|
|
455
589
|
let markdown;
|
|
456
590
|
if (mainContent.length === 0) {
|
|
457
|
-
console.
|
|
591
|
+
console.error('No main content found, using body');
|
|
458
592
|
$('script, style').remove();
|
|
459
|
-
markdown =
|
|
593
|
+
markdown = turndownService.turndown($('body').html() || '');
|
|
460
594
|
}
|
|
461
595
|
else {
|
|
462
|
-
markdown =
|
|
596
|
+
markdown = turndownService.turndown(mainContent.html() || '');
|
|
597
|
+
}
|
|
598
|
+
const trimmed = markdown.trim();
|
|
599
|
+
// A near-empty page or a JSON excerpt (client-rendered page data) is not a guide
|
|
600
|
+
if (trimmed.length < MIN_GUIDE_CHARS || /^[{[]\s*"/.test(trimmed)) {
|
|
601
|
+
throw new Error(`No content could be extracted from the guide page: ${sourceUrl}`);
|
|
463
602
|
}
|
|
603
|
+
return markdown;
|
|
604
|
+
}
|
|
605
|
+
formatGuide(markdown, guideId, sourceUrl, detailLevel) {
|
|
464
606
|
// Use intelligent extraction
|
|
465
|
-
const extractedContent =
|
|
466
|
-
|
|
467
|
-
return `# Spring Guide: ${guideId}\n\n**Source:** ${sourceUrl}\n**Detail Level:** ${detailLevel}\n\n${extractedContent}${needsTruncation ? '\n\n---\n*Content truncated for brevity. Use detail_level="full" for complete guide or visit the link above.*' : ''}`;
|
|
607
|
+
const { content: extractedContent, truncated } = extractContent(markdown, detailLevel);
|
|
608
|
+
return `# Spring Guide: ${guideId}\n\n**Source:** ${sourceUrl}\n**Detail Level:** ${detailLevel}\n\n${extractedContent}${truncated ? (detailLevel === 'full' ? '\n\n---\n*Content truncated at 50,000 characters even in full mode. Visit the link above for the complete guide.*' : '\n\n---\n*Content truncated for brevity. Use detail_level="full" for complete guide or visit the link above.*') : ''}`;
|
|
468
609
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const controller = new AbortController();
|
|
472
|
-
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
473
|
-
try {
|
|
474
|
-
const response = await fetch(url, {
|
|
475
|
-
signal: controller.signal,
|
|
476
|
-
headers: {
|
|
477
|
-
'User-Agent': 'Spring-Docs-MCP/1.2.4',
|
|
478
|
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
479
|
-
'Accept-Language': 'en-US,en;q=0.9'
|
|
480
|
-
}
|
|
481
|
-
});
|
|
482
|
-
clearTimeout(timeoutId);
|
|
483
|
-
return response;
|
|
484
|
-
}
|
|
485
|
-
catch (error) {
|
|
486
|
-
clearTimeout(timeoutId);
|
|
487
|
-
if (attempt === retries)
|
|
488
|
-
throw error;
|
|
489
|
-
console.error(`Retry ${attempt}/${retries} for ${url}:`, error instanceof Error ? error.message : 'Unknown error');
|
|
490
|
-
await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); // Exponential backoff
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
throw new Error('All retry attempts failed');
|
|
610
|
+
fetchWithRetry(url, timeout, retries) {
|
|
611
|
+
return fetchWithRetry(url, timeout, retries);
|
|
494
612
|
}
|
|
495
613
|
}
|
|
496
614
|
//# sourceMappingURL=springboot-docs-optimized.js.map
|