docorbit 0.1.0
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/LICENSE +21 -0
- package/README.md +660 -0
- package/apps/cli/bin/docorbit.js +8 -0
- package/apps/cli/src/commands/add.ts +44 -0
- package/apps/cli/src/commands/api.ts +38 -0
- package/apps/cli/src/commands/context.ts +47 -0
- package/apps/cli/src/commands/dashboard.ts +55 -0
- package/apps/cli/src/commands/diff.ts +30 -0
- package/apps/cli/src/commands/evaluate.ts +133 -0
- package/apps/cli/src/commands/examples.ts +39 -0
- package/apps/cli/src/commands/export.ts +89 -0
- package/apps/cli/src/commands/impact.ts +31 -0
- package/apps/cli/src/commands/init.ts +69 -0
- package/apps/cli/src/commands/inspect.ts +30 -0
- package/apps/cli/src/commands/mcp.ts +72 -0
- package/apps/cli/src/commands/pitfalls.ts +38 -0
- package/apps/cli/src/commands/recipes.ts +35 -0
- package/apps/cli/src/commands/search.ts +48 -0
- package/apps/cli/src/commands/update.ts +73 -0
- package/apps/cli/src/commands/verify.ts +48 -0
- package/apps/cli/src/formatters/colors.ts +23 -0
- package/apps/cli/src/formatters/inspection.ts +102 -0
- package/apps/cli/src/formatters/knowledge.ts +272 -0
- package/apps/cli/src/formatters/retrieval.ts +74 -0
- package/apps/cli/src/formatters/terminal.ts +6 -0
- package/apps/cli/src/formatters/verification.ts +126 -0
- package/apps/cli/src/index.ts +409 -0
- package/bin/docorbit.js +8 -0
- package/package.json +46 -0
- package/packages/core/src/dashboard/server.ts +314 -0
- package/packages/core/src/dashboard/ui.ts +586 -0
- package/packages/core/src/implementation-service.ts +451 -0
- package/packages/core/src/index.ts +7 -0
- package/packages/core/src/inspector.ts +71 -0
- package/packages/core/src/pipeline.ts +331 -0
- package/packages/crawler/src/config.ts +12 -0
- package/packages/crawler/src/fetcher.ts +185 -0
- package/packages/crawler/src/index.ts +2 -0
- package/packages/discovery/src/index.ts +31 -0
- package/packages/discovery/src/provider.ts +47 -0
- package/packages/discovery/src/providers/generic.ts +98 -0
- package/packages/discovery/src/providers/github.ts +61 -0
- package/packages/discovery/src/providers/llms-txt.ts +73 -0
- package/packages/discovery/src/providers/markdown.ts +48 -0
- package/packages/discovery/src/providers/openapi.ts +91 -0
- package/packages/discovery/src/providers/sitemap.ts +62 -0
- package/packages/discovery/src/providers/skill.ts +54 -0
- package/packages/discovery/src/ranker.ts +123 -0
- package/packages/evaluation/src/dataset.ts +963 -0
- package/packages/evaluation/src/index.ts +8 -0
- package/packages/evaluation/src/runner.ts +241 -0
- package/packages/evaluation/src/strategies/context7-runner.ts +269 -0
- package/packages/evaluation/src/strategies/docorbit-runner.ts +228 -0
- package/packages/evaluation/src/strategies/firecrawl-runner.ts +172 -0
- package/packages/evaluation/src/strategies/web-search-runner.ts +194 -0
- package/packages/evaluation/src/types.ts +34 -0
- package/packages/evaluation/src/version-matcher.ts +73 -0
- package/packages/export/src/agents-md.ts +200 -0
- package/packages/export/src/claude-md.ts +141 -0
- package/packages/export/src/docs-map.ts +150 -0
- package/packages/export/src/index.ts +6 -0
- package/packages/export/src/llms-txt.ts +96 -0
- package/packages/export/src/service.ts +250 -0
- package/packages/export/src/skill-md.ts +128 -0
- package/packages/mcp/src/index.ts +46 -0
- package/packages/mcp/src/resources/index.ts +189 -0
- package/packages/mcp/src/server.ts +278 -0
- package/packages/mcp/src/tools/analyze-impact.ts +74 -0
- package/packages/mcp/src/tools/check-api.ts +86 -0
- package/packages/mcp/src/tools/diff-docs.ts +68 -0
- package/packages/mcp/src/tools/export-context.ts +73 -0
- package/packages/mcp/src/tools/find-api.ts +99 -0
- package/packages/mcp/src/tools/find-example.ts +100 -0
- package/packages/mcp/src/tools/find-pitfall.ts +94 -0
- package/packages/mcp/src/tools/find-recipe.ts +98 -0
- package/packages/mcp/src/tools/get-doc.ts +130 -0
- package/packages/mcp/src/tools/get-docs-map.ts +64 -0
- package/packages/mcp/src/tools/get-version.ts +118 -0
- package/packages/mcp/src/tools/implementation-context.ts +88 -0
- package/packages/mcp/src/tools/index.ts +59 -0
- package/packages/mcp/src/tools/list-sources.ts +85 -0
- package/packages/mcp/src/tools/search-docs.ts +123 -0
- package/packages/mcp/src/tools/types.ts +28 -0
- package/packages/mcp/src/transports/http.ts +256 -0
- package/packages/mcp/src/transports/stdio.ts +105 -0
- package/packages/mcp/src/transports/types.ts +6 -0
- package/packages/mcp/src/types.ts +102 -0
- package/packages/normalizer/src/example-indexer.ts +240 -0
- package/packages/normalizer/src/html.ts +253 -0
- package/packages/normalizer/src/index.ts +8 -0
- package/packages/normalizer/src/llms.ts +83 -0
- package/packages/normalizer/src/openapi/endpoint-parser.ts +406 -0
- package/packages/normalizer/src/openapi/schema-resolver.ts +111 -0
- package/packages/normalizer/src/openapi.ts +2 -0
- package/packages/normalizer/src/page.ts +184 -0
- package/packages/normalizer/src/pitfall-extractor.ts +190 -0
- package/packages/normalizer/src/slicer.ts +455 -0
- package/packages/retrieval/src/engine.ts +120 -0
- package/packages/retrieval/src/index.ts +7 -0
- package/packages/retrieval/src/intent.ts +43 -0
- package/packages/retrieval/src/packer.ts +145 -0
- package/packages/retrieval/src/recipe-engine.ts +313 -0
- package/packages/retrieval/src/scorer.ts +139 -0
- package/packages/retrieval/src/weights.ts +31 -0
- package/packages/security/src/annotations.ts +112 -0
- package/packages/security/src/index.ts +2 -0
- package/packages/security/src/ssrf.ts +153 -0
- package/packages/shared/src/errors.ts +53 -0
- package/packages/shared/src/hashing.ts +23 -0
- package/packages/shared/src/index.ts +3 -0
- package/packages/shared/src/types.ts +881 -0
- package/packages/storage/src/db.ts +72 -0
- package/packages/storage/src/index.ts +11 -0
- package/packages/storage/src/interfaces.ts +115 -0
- package/packages/storage/src/repositories/api-repository.ts +219 -0
- package/packages/storage/src/repositories/chunk-repository.ts +316 -0
- package/packages/storage/src/repositories/example-repository.ts +206 -0
- package/packages/storage/src/repositories/page-repository.ts +205 -0
- package/packages/storage/src/repositories/pitfall-repository.ts +188 -0
- package/packages/storage/src/repositories/source-repository.ts +205 -0
- package/packages/storage/src/repository.ts +256 -0
- package/packages/storage/src/schema.ts +269 -0
- package/packages/storage/src/search-tokens.ts +28 -0
- package/packages/verification/src/diff-engine.ts +258 -0
- package/packages/verification/src/extractor.ts +339 -0
- package/packages/verification/src/impact-scanner.ts +203 -0
- package/packages/verification/src/index.ts +5 -0
- package/packages/verification/src/services.ts +238 -0
- package/packages/verification/src/verifier.ts +375 -0
- package/packages/workspace/src/detector.ts +143 -0
- package/packages/workspace/src/ecosystems/cargo.ts +84 -0
- package/packages/workspace/src/ecosystems/composer.ts +42 -0
- package/packages/workspace/src/ecosystems/go.ts +54 -0
- package/packages/workspace/src/ecosystems/index.ts +34 -0
- package/packages/workspace/src/ecosystems/maven.ts +34 -0
- package/packages/workspace/src/ecosystems/npm.ts +83 -0
- package/packages/workspace/src/ecosystems/pub.ts +40 -0
- package/packages/workspace/src/ecosystems/pypi.ts +100 -0
- package/packages/workspace/src/ecosystems/rubygems.ts +30 -0
- package/packages/workspace/src/ecosystems/types.ts +18 -0
- package/packages/workspace/src/index.ts +5 -0
- package/packages/workspace/src/lockfile.ts +194 -0
- package/packages/workspace/src/resolver.ts +234 -0
- package/packages/workspace/src/semver.ts +259 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiscoveredSource,
|
|
3
|
+
NormalizedPage,
|
|
4
|
+
CrawlerConfig,
|
|
5
|
+
CrawlPolicy,
|
|
6
|
+
Target,
|
|
7
|
+
SourcePurpose,
|
|
8
|
+
} from '../../shared/src/index.ts';
|
|
9
|
+
import { validateTargetUrl } from '../../security/src/index.ts';
|
|
10
|
+
import { SecureFetcher, DEFAULT_CRAWLER_CONFIG } from '../../crawler/src/index.ts';
|
|
11
|
+
import { createDefaultDiscoveryCoordinator, rankSources } from '../../discovery/src/index.ts';
|
|
12
|
+
import {
|
|
13
|
+
buildNormalizedPage,
|
|
14
|
+
parseLlmsTxt,
|
|
15
|
+
isValidLlmsTxt,
|
|
16
|
+
slicePageIntoChunks,
|
|
17
|
+
parseOpenApiEndpoints,
|
|
18
|
+
detectOpenApiSpec,
|
|
19
|
+
extractIndexedExamples,
|
|
20
|
+
extractPitfalls,
|
|
21
|
+
} from '../../normalizer/src/index.ts';
|
|
22
|
+
import { DocOrbitRepository } from '../../storage/src/index.ts';
|
|
23
|
+
|
|
24
|
+
export interface IngestionOptions {
|
|
25
|
+
crawlerConfig?: Partial<CrawlerConfig>;
|
|
26
|
+
crawlPolicy?: Partial<CrawlPolicy>;
|
|
27
|
+
allowLocalhostForTesting?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface IngestionResult {
|
|
31
|
+
targetUrl: string;
|
|
32
|
+
target?: Target;
|
|
33
|
+
primarySourceId: string;
|
|
34
|
+
sourcesDiscovered: DiscoveredSource[];
|
|
35
|
+
selectedSources: Array<{
|
|
36
|
+
purpose: SourcePurpose;
|
|
37
|
+
sourceId: string;
|
|
38
|
+
}>;
|
|
39
|
+
pages: NormalizedPage[];
|
|
40
|
+
pagesDiscovered: number;
|
|
41
|
+
pagesFetched: number;
|
|
42
|
+
pagesStored: number;
|
|
43
|
+
snapshotId: string;
|
|
44
|
+
warnings: string[];
|
|
45
|
+
errors: Array<{
|
|
46
|
+
url: string;
|
|
47
|
+
error: string;
|
|
48
|
+
}>;
|
|
49
|
+
durationMs: number;
|
|
50
|
+
stats: {
|
|
51
|
+
totalPages: number;
|
|
52
|
+
totalBytes: number;
|
|
53
|
+
totalEstimatedTokens: number;
|
|
54
|
+
totalCodeExamples: number;
|
|
55
|
+
machineReadableSources: number;
|
|
56
|
+
totalChunks: number;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
export class IngestionPipeline {
|
|
62
|
+
private repository: DocOrbitRepository;
|
|
63
|
+
private fetcher: SecureFetcher;
|
|
64
|
+
private config: CrawlerConfig;
|
|
65
|
+
private policy: CrawlPolicy;
|
|
66
|
+
private allowLocalhostForTesting: boolean;
|
|
67
|
+
|
|
68
|
+
constructor(repository: DocOrbitRepository, options: IngestionOptions = {}) {
|
|
69
|
+
this.repository = repository;
|
|
70
|
+
this.config = {
|
|
71
|
+
...DEFAULT_CRAWLER_CONFIG,
|
|
72
|
+
...options.crawlerConfig,
|
|
73
|
+
};
|
|
74
|
+
this.allowLocalhostForTesting = options.allowLocalhostForTesting ?? false;
|
|
75
|
+
this.policy = {
|
|
76
|
+
purpose: 'conceptual',
|
|
77
|
+
maxPages: this.config.maxPages,
|
|
78
|
+
maxDepth: this.config.maxDepth || 2,
|
|
79
|
+
followLlmsReferences: true,
|
|
80
|
+
followExternalDomains: false,
|
|
81
|
+
...options.crawlPolicy,
|
|
82
|
+
};
|
|
83
|
+
this.fetcher = new SecureFetcher({
|
|
84
|
+
timeoutMs: this.config.timeoutMs,
|
|
85
|
+
maxBytes: this.config.maxBytesPerResponse,
|
|
86
|
+
maxRedirects: this.config.maxRedirects,
|
|
87
|
+
userAgent: this.config.userAgent,
|
|
88
|
+
allowLocalhostForTesting: this.allowLocalhostForTesting,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async ingest(targetUrl: string): Promise<IngestionResult> {
|
|
93
|
+
const startTime = Date.now();
|
|
94
|
+
|
|
95
|
+
// 1. SSRF & Protocol validation before making any discovery probes
|
|
96
|
+
await validateTargetUrl(targetUrl, {
|
|
97
|
+
allowLocalhostForTesting: this.allowLocalhostForTesting,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const coordinator = createDefaultDiscoveryCoordinator();
|
|
101
|
+
const discovered = await coordinator.discoverAll(targetUrl, this.fetcher);
|
|
102
|
+
|
|
103
|
+
const sourceIds: Record<string, string> = {};
|
|
104
|
+
for (const s of discovered) {
|
|
105
|
+
const id = this.repository.saveSource(s);
|
|
106
|
+
sourceIds[s.url] = id;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const conceptualRank = rankSources(discovered, 'conceptual');
|
|
110
|
+
const apiRank = rankSources(discovered, 'api');
|
|
111
|
+
|
|
112
|
+
const primarySource = conceptualRank.recommended || discovered[0];
|
|
113
|
+
const primarySourceId = primarySource
|
|
114
|
+
? (sourceIds[primarySource.url] || this.repository.saveSource(primarySource))
|
|
115
|
+
: this.repository.saveSource({
|
|
116
|
+
url: targetUrl,
|
|
117
|
+
type: 'web',
|
|
118
|
+
discoveredBy: 'direct',
|
|
119
|
+
status: 'valid',
|
|
120
|
+
confidence: 1.0,
|
|
121
|
+
authority: 'official',
|
|
122
|
+
machineReadable: false,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const purposes: SourcePurpose[] = ['navigation', 'conceptual', 'api', 'examples', 'implementation'];
|
|
126
|
+
const selectedSources = purposes.map(p => {
|
|
127
|
+
const ranked = rankSources(discovered, p);
|
|
128
|
+
return {
|
|
129
|
+
purpose: p,
|
|
130
|
+
sourceId: ranked.recommended ? (sourceIds[ranked.recommended.url] || primarySourceId) : primarySourceId,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const queue: Array<{ url: string; depth: number }> = [];
|
|
135
|
+
const visited = new Set<string>();
|
|
136
|
+
const ingestedPages: NormalizedPage[] = [];
|
|
137
|
+
const warnings: string[] = [];
|
|
138
|
+
const errors: Array<{ url: string; error: string }> = [];
|
|
139
|
+
let pagesDiscovered = 0;
|
|
140
|
+
|
|
141
|
+
const enqueue = (url: string, depth: number) => {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = new URL(url);
|
|
144
|
+
const normalized = `${parsed.origin}${parsed.pathname}`;
|
|
145
|
+
pagesDiscovered++;
|
|
146
|
+
if (depth > this.policy.maxDepth) return;
|
|
147
|
+
if (!visited.has(normalized) && queue.length < this.policy.maxPages) {
|
|
148
|
+
queue.push({ url: normalized, depth });
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Ignore
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// 1. Prioritize targetUrl as the primary entry point (depth: 0)
|
|
156
|
+
enqueue(targetUrl, 0);
|
|
157
|
+
|
|
158
|
+
// 2. If an OpenAPI spec is available, enqueue it (depth: 0)
|
|
159
|
+
if (apiRank.recommended && apiRank.recommended.type === 'openapi' && apiRank.recommended.url !== targetUrl) {
|
|
160
|
+
enqueue(apiRank.recommended.url, 0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 3. If llms.txt or llms-full.txt is available, enqueue it (depth: 0)
|
|
164
|
+
const llmsFull = discovered.find(s => s.type === 'llms_full_txt' && s.status === 'valid');
|
|
165
|
+
const llms = discovered.find(s => s.type === 'llms_txt' && s.status === 'valid');
|
|
166
|
+
|
|
167
|
+
if (llmsFull && llmsFull.url !== targetUrl) {
|
|
168
|
+
enqueue(llmsFull.url, 0);
|
|
169
|
+
} else if (llms && llms.url !== targetUrl) {
|
|
170
|
+
enqueue(llms.url, 0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (primarySource && primarySource.url !== targetUrl) {
|
|
174
|
+
enqueue(primarySource.url, 0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const targetOrigin = new URL(targetUrl).origin;
|
|
178
|
+
|
|
179
|
+
while (queue.length > 0 && ingestedPages.length < this.policy.maxPages) {
|
|
180
|
+
const item = queue.shift()!;
|
|
181
|
+
const currentUrl = item.url;
|
|
182
|
+
const currentDepth = item.depth;
|
|
183
|
+
|
|
184
|
+
if (visited.has(currentUrl)) continue;
|
|
185
|
+
visited.add(currentUrl);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const res = await this.fetcher.fetch(currentUrl);
|
|
189
|
+
if (res.status < 200 || res.status >= 300) {
|
|
190
|
+
errors.push({ url: currentUrl, error: `HTTP status ${res.status}` });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const page = buildNormalizedPage({
|
|
195
|
+
sourceId: primarySourceId,
|
|
196
|
+
url: res.finalUrl || currentUrl,
|
|
197
|
+
rawContent: res.body,
|
|
198
|
+
contentType: res.contentType,
|
|
199
|
+
sourceUrl: primarySource?.url || targetUrl,
|
|
200
|
+
targetUrl,
|
|
201
|
+
discoveredBy: primarySource?.discoveredBy || 'direct',
|
|
202
|
+
fetchedAt: new Date().toISOString(),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
this.repository.savePage(page);
|
|
206
|
+
ingestedPages.push(page);
|
|
207
|
+
|
|
208
|
+
// Subpage link discovery
|
|
209
|
+
if (isValidLlmsTxt(res.body)) {
|
|
210
|
+
if (this.policy.followLlmsReferences && currentDepth < this.policy.maxDepth) {
|
|
211
|
+
const llmsDoc = parseLlmsTxt(res.body, currentUrl);
|
|
212
|
+
for (const sec of llmsDoc.sections) {
|
|
213
|
+
for (const lnk of sec.links) {
|
|
214
|
+
if (ingestedPages.length + queue.length < this.policy.maxPages) {
|
|
215
|
+
try {
|
|
216
|
+
const parsedLnk = new URL(lnk.url);
|
|
217
|
+
if (this.policy.followExternalDomains || parsedLnk.origin === targetOrigin) {
|
|
218
|
+
enqueue(lnk.url, currentDepth + 1);
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// Ignore
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
if (currentDepth < this.policy.maxDepth) {
|
|
229
|
+
for (const lnk of page.links) {
|
|
230
|
+
if (ingestedPages.length + queue.length < this.policy.maxPages) {
|
|
231
|
+
try {
|
|
232
|
+
const linkUrl = new URL(lnk.url);
|
|
233
|
+
if (this.policy.followExternalDomains || linkUrl.origin === targetOrigin) {
|
|
234
|
+
enqueue(lnk.url, currentDepth + 1);
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
// Ignore
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch (err: unknown) {
|
|
244
|
+
errors.push({ url: currentUrl, error: err instanceof Error ? err.message : String(err) });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const snapshotId = this.repository.createSnapshot(primarySourceId, {
|
|
249
|
+
targetUrl,
|
|
250
|
+
pageCount: ingestedPages.length,
|
|
251
|
+
ingestedAt: new Date().toISOString(),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Milestone 2: Semantic slicing of pages into standalone DocumentChunks
|
|
255
|
+
let totalChunks = 0;
|
|
256
|
+
for (const p of ingestedPages) {
|
|
257
|
+
const slicingResult = slicePageIntoChunks(p, snapshotId);
|
|
258
|
+
this.repository.saveChunks(
|
|
259
|
+
slicingResult.chunks,
|
|
260
|
+
slicingResult.relationships,
|
|
261
|
+
slicingResult.codeSnippets,
|
|
262
|
+
slicingResult.symbols
|
|
263
|
+
);
|
|
264
|
+
totalChunks += slicingResult.chunks.length;
|
|
265
|
+
|
|
266
|
+
// Milestone 4: API Endpoints
|
|
267
|
+
if (p.openApiSummary || detectOpenApiSpec(p.content, p.url)) {
|
|
268
|
+
const endpoints = parseOpenApiEndpoints(p.content, p.id, snapshotId, p.url, p.docVersion);
|
|
269
|
+
if (endpoints.length > 0) {
|
|
270
|
+
this.repository.saveApiEndpoints(endpoints);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Milestone 4: Indexed Examples
|
|
275
|
+
const examples = extractIndexedExamples(p, slicingResult.chunks, primarySource?.authority || 'official', p.docVersion, snapshotId);
|
|
276
|
+
if (examples.length > 0) {
|
|
277
|
+
this.repository.saveIndexedExamples(examples);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Milestone 4: Pitfalls
|
|
281
|
+
const pitfalls = extractPitfalls(p, slicingResult.chunks, p.docVersion, snapshotId);
|
|
282
|
+
if (pitfalls.length > 0) {
|
|
283
|
+
this.repository.savePitfalls(pitfalls);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const durationMs = Date.now() - startTime;
|
|
288
|
+
|
|
289
|
+
let totalBytes = 0;
|
|
290
|
+
let totalEstimatedTokens = 0;
|
|
291
|
+
let totalCodeExamples = 0;
|
|
292
|
+
|
|
293
|
+
for (const p of ingestedPages) {
|
|
294
|
+
totalBytes += p.rawBytes;
|
|
295
|
+
totalEstimatedTokens += p.estimatedTokens;
|
|
296
|
+
totalCodeExamples += p.codeExamples.length;
|
|
297
|
+
if (p.securityAnnotations.length > 0) {
|
|
298
|
+
warnings.push(`Page "${p.url}" generated ${p.securityAnnotations.length} security alert(s).`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
targetUrl,
|
|
304
|
+
target: {
|
|
305
|
+
type: 'url',
|
|
306
|
+
value: targetUrl,
|
|
307
|
+
normalizedUrl: targetUrl,
|
|
308
|
+
},
|
|
309
|
+
primarySourceId,
|
|
310
|
+
sourcesDiscovered: discovered,
|
|
311
|
+
selectedSources,
|
|
312
|
+
pages: ingestedPages,
|
|
313
|
+
pagesDiscovered,
|
|
314
|
+
pagesFetched: visited.size,
|
|
315
|
+
pagesStored: ingestedPages.length,
|
|
316
|
+
snapshotId,
|
|
317
|
+
warnings,
|
|
318
|
+
errors,
|
|
319
|
+
durationMs,
|
|
320
|
+
stats: {
|
|
321
|
+
totalPages: ingestedPages.length,
|
|
322
|
+
totalBytes,
|
|
323
|
+
totalEstimatedTokens,
|
|
324
|
+
totalCodeExamples,
|
|
325
|
+
machineReadableSources: discovered.filter(s => s.status === 'valid' && s.machineReadable).length,
|
|
326
|
+
totalChunks,
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CrawlerConfig } from '../../shared/src/index.ts';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_CRAWLER_CONFIG: CrawlerConfig = {
|
|
4
|
+
maxPages: 50,
|
|
5
|
+
maxDepth: 3,
|
|
6
|
+
maxBytesPerResponse: 10 * 1024 * 1024, // 10MB
|
|
7
|
+
timeoutMs: 10000, // 10 seconds
|
|
8
|
+
maxRedirects: 5,
|
|
9
|
+
concurrency: 5,
|
|
10
|
+
allowedProtocols: ['http:', 'https:'],
|
|
11
|
+
userAgent: 'DocOrbit/1.0 (+https://github.com/docorbit/docorbit)',
|
|
12
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FetchTimeoutError,
|
|
3
|
+
PayloadTooLargeError,
|
|
4
|
+
DocOrbitError,
|
|
5
|
+
} from '../../shared/src/index.ts';
|
|
6
|
+
import { validateTargetUrl } from '../../security/src/index.ts';
|
|
7
|
+
import type { SsrfValidationOptions } from '../../security/src/index.ts';
|
|
8
|
+
import { DEFAULT_CRAWLER_CONFIG } from './config.ts';
|
|
9
|
+
|
|
10
|
+
export interface FetchResult {
|
|
11
|
+
url: string;
|
|
12
|
+
finalUrl: string;
|
|
13
|
+
status: number;
|
|
14
|
+
statusText: string;
|
|
15
|
+
headers: Record<string, string>;
|
|
16
|
+
body: string;
|
|
17
|
+
contentType: string;
|
|
18
|
+
bytesRead: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FetchOptions extends SsrfValidationOptions {
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
maxBytes?: number;
|
|
24
|
+
maxRedirects?: number;
|
|
25
|
+
userAgent?: string;
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
method?: string;
|
|
28
|
+
fetchFn?: typeof fetch;
|
|
29
|
+
allowCrossDomainRedirects?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class SecureFetcher {
|
|
33
|
+
private timeoutMs: number;
|
|
34
|
+
private maxBytes: number;
|
|
35
|
+
private maxRedirects: number;
|
|
36
|
+
private userAgent: string;
|
|
37
|
+
private allowLocalhostForTesting: boolean;
|
|
38
|
+
private fetchFn: typeof fetch;
|
|
39
|
+
|
|
40
|
+
constructor(defaults: Partial<FetchOptions> = {}) {
|
|
41
|
+
this.timeoutMs = defaults.timeoutMs ?? DEFAULT_CRAWLER_CONFIG.timeoutMs;
|
|
42
|
+
this.maxBytes = defaults.maxBytes ?? DEFAULT_CRAWLER_CONFIG.maxBytesPerResponse;
|
|
43
|
+
this.maxRedirects = defaults.maxRedirects ?? DEFAULT_CRAWLER_CONFIG.maxRedirects;
|
|
44
|
+
this.userAgent = defaults.userAgent ?? DEFAULT_CRAWLER_CONFIG.userAgent;
|
|
45
|
+
this.allowLocalhostForTesting = defaults.allowLocalhostForTesting ?? false;
|
|
46
|
+
this.fetchFn = defaults.fetchFn ?? globalThis.fetch;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async fetch(urlStr: string, options: FetchOptions = {}): Promise<FetchResult> {
|
|
50
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
51
|
+
const maxBytes = options.maxBytes ?? this.maxBytes;
|
|
52
|
+
const maxRedirects = options.maxRedirects ?? this.maxRedirects;
|
|
53
|
+
const userAgent = options.userAgent ?? this.userAgent;
|
|
54
|
+
const method = options.method ?? 'GET';
|
|
55
|
+
const allowLocalhost = options.allowLocalhostForTesting ?? this.allowLocalhostForTesting;
|
|
56
|
+
const activeFetch = options.fetchFn ?? this.fetchFn;
|
|
57
|
+
|
|
58
|
+
let currentUrl = urlStr;
|
|
59
|
+
let redirectCount = 0;
|
|
60
|
+
const seenRedirects = new Set<string>();
|
|
61
|
+
seenRedirects.add(currentUrl);
|
|
62
|
+
|
|
63
|
+
while (redirectCount <= maxRedirects) {
|
|
64
|
+
// 1. SSRF & Protocol validation before making the hop
|
|
65
|
+
await validateTargetUrl(currentUrl, {
|
|
66
|
+
allowLocalhostForTesting: allowLocalhost,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timeoutId = setTimeout(() => {
|
|
71
|
+
controller.abort(new FetchTimeoutError(`Request timed out after ${timeoutMs}ms`, timeoutMs));
|
|
72
|
+
}, timeoutMs);
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const reqHeaders: Record<string, string> = {
|
|
76
|
+
'User-Agent': userAgent,
|
|
77
|
+
Accept: 'text/html,application/xhtml+xml,text/plain,text/markdown,application/json,*/*',
|
|
78
|
+
...options.headers,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const res = await activeFetch(currentUrl, {
|
|
82
|
+
method,
|
|
83
|
+
headers: reqHeaders,
|
|
84
|
+
redirect: 'manual',
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// 2. Handle HTTP redirects (301, 302, 303, 307, 308)
|
|
89
|
+
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
|
90
|
+
const location = res.headers.get('location');
|
|
91
|
+
if (!location) {
|
|
92
|
+
throw new DocOrbitError(`Redirect response (${res.status}) missing Location header.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
redirectCount++;
|
|
96
|
+
if (redirectCount > maxRedirects) {
|
|
97
|
+
throw new DocOrbitError(`Exceeded maximum allowed redirects (${maxRedirects}).`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const nextUrl = new URL(location, currentUrl).href;
|
|
101
|
+
|
|
102
|
+
// Check for redirect loop
|
|
103
|
+
if (seenRedirects.has(nextUrl)) {
|
|
104
|
+
throw new DocOrbitError(`Redirect loop detected: "${nextUrl}" already visited in redirect chain.`);
|
|
105
|
+
}
|
|
106
|
+
seenRedirects.add(nextUrl);
|
|
107
|
+
|
|
108
|
+
// Enforce domain/origin policy
|
|
109
|
+
if (options.allowCrossDomainRedirects === false) {
|
|
110
|
+
const origOrigin = new URL(urlStr).origin;
|
|
111
|
+
const nextOrigin = new URL(nextUrl).origin;
|
|
112
|
+
if (origOrigin !== nextOrigin) {
|
|
113
|
+
throw new DocOrbitError(`Cross-origin redirect from "${origOrigin}" to "${nextOrigin}" prohibited by policy.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
currentUrl = nextUrl;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 3. Extract response headers
|
|
122
|
+
const headers: Record<string, string> = {};
|
|
123
|
+
res.headers.forEach((val, key) => {
|
|
124
|
+
headers[key.toLowerCase()] = val;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const contentType = headers['content-type'] || 'application/octet-stream';
|
|
128
|
+
|
|
129
|
+
// 4. Stream response body with byte counting
|
|
130
|
+
let bytesRead = 0;
|
|
131
|
+
const chunks: Uint8Array[] = [];
|
|
132
|
+
|
|
133
|
+
if (res.body) {
|
|
134
|
+
const reader = res.body.getReader();
|
|
135
|
+
try {
|
|
136
|
+
while (true) {
|
|
137
|
+
const { done, value } = await reader.read();
|
|
138
|
+
if (done) break;
|
|
139
|
+
if (value) {
|
|
140
|
+
bytesRead += value.length;
|
|
141
|
+
if (bytesRead > maxBytes) {
|
|
142
|
+
controller.abort();
|
|
143
|
+
throw new PayloadTooLargeError(
|
|
144
|
+
`Response exceeded maximum allowed size of ${maxBytes} bytes (read ${bytesRead} bytes).`,
|
|
145
|
+
bytesRead,
|
|
146
|
+
maxBytes
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
chunks.push(value);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
reader.releaseLock();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const totalBuffer = Buffer.concat(chunks);
|
|
158
|
+
const bodyText = totalBuffer.toString('utf-8');
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
url: urlStr,
|
|
162
|
+
finalUrl: currentUrl,
|
|
163
|
+
status: res.status,
|
|
164
|
+
statusText: res.statusText,
|
|
165
|
+
headers,
|
|
166
|
+
body: bodyText,
|
|
167
|
+
contentType,
|
|
168
|
+
bytesRead,
|
|
169
|
+
};
|
|
170
|
+
} catch (err: unknown) {
|
|
171
|
+
if (err instanceof DocOrbitError) throw err;
|
|
172
|
+
if (controller.signal.aborted) {
|
|
173
|
+
const reason = controller.signal.reason;
|
|
174
|
+
if (reason instanceof DocOrbitError) throw reason;
|
|
175
|
+
throw new FetchTimeoutError(`Request timed out after ${timeoutMs}ms`, timeoutMs);
|
|
176
|
+
}
|
|
177
|
+
throw new DocOrbitError(`Fetch failure for "${currentUrl}": ${err instanceof Error ? err.message : String(err)}`);
|
|
178
|
+
} finally {
|
|
179
|
+
clearTimeout(timeoutId);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
throw new DocOrbitError(`Exceeded maximum allowed redirects (${maxRedirects}).`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { DiscoveryCoordinator } from './provider.ts';
|
|
2
|
+
import { LlmsTxtProvider } from './providers/llms-txt.ts';
|
|
3
|
+
import { OpenApiProvider } from './providers/openapi.ts';
|
|
4
|
+
import { MarkdownProvider } from './providers/markdown.ts';
|
|
5
|
+
import { SitemapProvider } from './providers/sitemap.ts';
|
|
6
|
+
import { GithubProvider } from './providers/github.ts';
|
|
7
|
+
import { SkillProvider } from './providers/skill.ts';
|
|
8
|
+
import { GenericWebProvider } from './providers/generic.ts';
|
|
9
|
+
|
|
10
|
+
export * from './provider.ts';
|
|
11
|
+
export * from './ranker.ts';
|
|
12
|
+
export * from './providers/llms-txt.ts';
|
|
13
|
+
export * from './providers/openapi.ts';
|
|
14
|
+
export * from './providers/markdown.ts';
|
|
15
|
+
export * from './providers/sitemap.ts';
|
|
16
|
+
export * from './providers/github.ts';
|
|
17
|
+
export * from './providers/skill.ts';
|
|
18
|
+
export * from './providers/generic.ts';
|
|
19
|
+
|
|
20
|
+
export function createDefaultDiscoveryCoordinator(): DiscoveryCoordinator {
|
|
21
|
+
const coordinator = new DiscoveryCoordinator();
|
|
22
|
+
coordinator
|
|
23
|
+
.register(new LlmsTxtProvider())
|
|
24
|
+
.register(new OpenApiProvider())
|
|
25
|
+
.register(new MarkdownProvider())
|
|
26
|
+
.register(new SitemapProvider())
|
|
27
|
+
.register(new SkillProvider())
|
|
28
|
+
.register(new GithubProvider())
|
|
29
|
+
.register(new GenericWebProvider());
|
|
30
|
+
return coordinator;
|
|
31
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { DiscoveredSource } from '../../shared/src/index.ts';
|
|
2
|
+
import { SecureFetcher } from '../../crawler/src/index.ts';
|
|
3
|
+
|
|
4
|
+
export interface DiscoveryProvider {
|
|
5
|
+
name: string;
|
|
6
|
+
discover(targetUrl: string, fetcher: SecureFetcher): Promise<DiscoveredSource[]>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class DiscoveryCoordinator {
|
|
10
|
+
private providers: DiscoveryProvider[] = [];
|
|
11
|
+
|
|
12
|
+
register(provider: DiscoveryProvider): this {
|
|
13
|
+
this.providers.push(provider);
|
|
14
|
+
return this;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
getProviders(): DiscoveryProvider[] {
|
|
18
|
+
return [...this.providers];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async discoverAll(targetUrl: string, fetcher: SecureFetcher): Promise<DiscoveredSource[]> {
|
|
22
|
+
const results = await Promise.allSettled(
|
|
23
|
+
this.providers.map(p => p.discover(targetUrl, fetcher))
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const allSources: DiscoveredSource[] = [];
|
|
27
|
+
for (const res of results) {
|
|
28
|
+
if (res.status === 'fulfilled') {
|
|
29
|
+
allSources.push(...res.value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Deduplicate sources by normalized URL
|
|
34
|
+
const seen = new Set<string>();
|
|
35
|
+
const deduped: DiscoveredSource[] = [];
|
|
36
|
+
|
|
37
|
+
for (const s of allSources) {
|
|
38
|
+
const normalizedUrl = s.url.replace(/\/$/, '').toLowerCase();
|
|
39
|
+
if (!seen.has(normalizedUrl)) {
|
|
40
|
+
seen.add(normalizedUrl);
|
|
41
|
+
deduped.push(s);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return deduped;
|
|
46
|
+
}
|
|
47
|
+
}
|