docugraph 0.1.0__py3-none-any.whl
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.
- docugraph/__init__.py +24 -0
- docugraph/_version.py +28 -0
- docugraph/agents/__init__.py +27 -0
- docugraph/agents/coding_agent.py +532 -0
- docugraph/agents/tools/__init__.py +62 -0
- docugraph/agents/tools/graph_query.py +203 -0
- docugraph/agents/tools/memory_ops.py +305 -0
- docugraph/agents/tools/search_code.py +305 -0
- docugraph/agents/tools/search_docs.py +148 -0
- docugraph/core/__init__.py +34 -0
- docugraph/core/config.py +183 -0
- docugraph/core/embeddings.py +394 -0
- docugraph/core/llm.py +892 -0
- docugraph/core/models.py +145 -0
- docugraph/ingestion/__init__.py +64 -0
- docugraph/ingestion/api_docs.py +646 -0
- docugraph/ingestion/chunker.py +382 -0
- docugraph/ingestion/crawler.py +305 -0
- docugraph/ingestion/git_indexer.py +556 -0
- docugraph/ingestion/local_files.py +516 -0
- docugraph/interfaces/__init__.py +1 -0
- docugraph/interfaces/cli.py +983 -0
- docugraph/interfaces/mcp_server.py +918 -0
- docugraph/retrieval/__init__.py +54 -0
- docugraph/retrieval/context_builder.py +522 -0
- docugraph/retrieval/hybrid_search.py +534 -0
- docugraph/retrieval/reranker.py +532 -0
- docugraph/storage/__init__.py +14 -0
- docugraph/storage/graph_store.py +602 -0
- docugraph/storage/memory_store.py +529 -0
- docugraph/storage/vector_store.py +367 -0
- docugraph-0.1.0.dist-info/METADATA +354 -0
- docugraph-0.1.0.dist-info/RECORD +37 -0
- docugraph-0.1.0.dist-info/WHEEL +4 -0
- docugraph-0.1.0.dist-info/entry_points.txt +3 -0
- docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
- docugraph-0.1.0.dist-info/licenses/NOTICE +10 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Document chunking strategies."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from docugraph.core.models import Chunk, Document
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChunkingStrategy(StrEnum):
|
|
11
|
+
"""Available chunking strategies."""
|
|
12
|
+
|
|
13
|
+
FIXED = "fixed" # Fixed size with overlap
|
|
14
|
+
SEMANTIC = "semantic" # By markdown headings/sections
|
|
15
|
+
SENTENCE = "sentence" # By sentences
|
|
16
|
+
PARAGRAPH = "paragraph" # By paragraphs
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ChunkerConfig:
|
|
21
|
+
"""Configuration for the chunker."""
|
|
22
|
+
|
|
23
|
+
strategy: ChunkingStrategy = ChunkingStrategy.SEMANTIC
|
|
24
|
+
chunk_size: int = 1000 # Target characters per chunk
|
|
25
|
+
chunk_overlap: int = 200 # Overlap between chunks
|
|
26
|
+
min_chunk_size: int = 100 # Minimum chunk size
|
|
27
|
+
max_chunk_size: int = 2000 # Maximum chunk size
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Chunker:
|
|
31
|
+
"""Document chunker with multiple strategies."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, config: ChunkerConfig | None = None):
|
|
34
|
+
"""Initialize the chunker.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
config: Chunker configuration.
|
|
38
|
+
"""
|
|
39
|
+
self._config = config or ChunkerConfig()
|
|
40
|
+
|
|
41
|
+
def chunk_document(self, document: Document) -> list[Chunk]:
|
|
42
|
+
"""Chunk a document into smaller pieces.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
document: The document to chunk.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of chunks.
|
|
49
|
+
"""
|
|
50
|
+
strategy = self._config.strategy
|
|
51
|
+
|
|
52
|
+
if strategy == ChunkingStrategy.SEMANTIC:
|
|
53
|
+
return self._chunk_semantic(document)
|
|
54
|
+
elif strategy == ChunkingStrategy.SENTENCE:
|
|
55
|
+
return self._chunk_sentences(document)
|
|
56
|
+
elif strategy == ChunkingStrategy.PARAGRAPH:
|
|
57
|
+
return self._chunk_paragraphs(document)
|
|
58
|
+
else:
|
|
59
|
+
return self._chunk_fixed(document)
|
|
60
|
+
|
|
61
|
+
def _chunk_fixed(self, document: Document) -> list[Chunk]:
|
|
62
|
+
"""Chunk document with fixed size windows."""
|
|
63
|
+
content = document.content
|
|
64
|
+
chunks: list[Chunk] = []
|
|
65
|
+
|
|
66
|
+
chunk_size = self._config.chunk_size
|
|
67
|
+
overlap = self._config.chunk_overlap
|
|
68
|
+
|
|
69
|
+
start = 0
|
|
70
|
+
while start < len(content):
|
|
71
|
+
end = start + chunk_size
|
|
72
|
+
|
|
73
|
+
# Try to break at sentence/paragraph boundary
|
|
74
|
+
if end < len(content):
|
|
75
|
+
# Look for paragraph break
|
|
76
|
+
para_break = content.rfind("\n\n", start + chunk_size // 2, end + 100)
|
|
77
|
+
if para_break > start:
|
|
78
|
+
end = para_break
|
|
79
|
+
else:
|
|
80
|
+
# Look for sentence break
|
|
81
|
+
sent_break = content.rfind(". ", start + chunk_size // 2, end + 50)
|
|
82
|
+
if sent_break > start:
|
|
83
|
+
end = sent_break + 1
|
|
84
|
+
|
|
85
|
+
chunk_content = content[start:end].strip()
|
|
86
|
+
|
|
87
|
+
if len(chunk_content) >= self._config.min_chunk_size:
|
|
88
|
+
chunks.append(
|
|
89
|
+
Chunk(
|
|
90
|
+
document_id=document.id,
|
|
91
|
+
content=chunk_content,
|
|
92
|
+
start_char=start,
|
|
93
|
+
end_char=end,
|
|
94
|
+
metadata={
|
|
95
|
+
"source_url": document.source_url,
|
|
96
|
+
"source_path": document.source_path,
|
|
97
|
+
"title": document.title,
|
|
98
|
+
"chunk_index": len(chunks),
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
start = end - overlap
|
|
104
|
+
if start >= len(content):
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
return chunks
|
|
108
|
+
|
|
109
|
+
def _chunk_semantic(self, document: Document) -> list[Chunk]:
|
|
110
|
+
"""Chunk document by markdown sections."""
|
|
111
|
+
content = document.content
|
|
112
|
+
chunks: list[Chunk] = []
|
|
113
|
+
|
|
114
|
+
# Split by headings
|
|
115
|
+
heading_pattern = r"^(#{1,6})\s+(.+?)$"
|
|
116
|
+
sections: list[tuple[int, int, str, str]] = [] # (start, level, title, content)
|
|
117
|
+
|
|
118
|
+
lines = content.split("\n")
|
|
119
|
+
current_section_start = 0
|
|
120
|
+
current_title = document.title or "Introduction"
|
|
121
|
+
current_level = 0
|
|
122
|
+
section_lines: list[str] = []
|
|
123
|
+
|
|
124
|
+
for i, line in enumerate(lines):
|
|
125
|
+
match = re.match(heading_pattern, line)
|
|
126
|
+
if match:
|
|
127
|
+
# Save previous section
|
|
128
|
+
if section_lines:
|
|
129
|
+
section_content = "\n".join(section_lines).strip()
|
|
130
|
+
if section_content:
|
|
131
|
+
sections.append(
|
|
132
|
+
(
|
|
133
|
+
current_section_start,
|
|
134
|
+
current_level,
|
|
135
|
+
current_title,
|
|
136
|
+
section_content,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Start new section
|
|
141
|
+
current_level = len(match.group(1))
|
|
142
|
+
current_title = match.group(2).strip()
|
|
143
|
+
current_section_start = sum(len(line) + 1 for line in lines[:i])
|
|
144
|
+
section_lines = [line]
|
|
145
|
+
else:
|
|
146
|
+
section_lines.append(line)
|
|
147
|
+
|
|
148
|
+
# Add last section
|
|
149
|
+
if section_lines:
|
|
150
|
+
section_content = "\n".join(section_lines).strip()
|
|
151
|
+
if section_content:
|
|
152
|
+
sections.append(
|
|
153
|
+
(
|
|
154
|
+
current_section_start,
|
|
155
|
+
current_level,
|
|
156
|
+
current_title,
|
|
157
|
+
section_content,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Convert sections to chunks, splitting large ones
|
|
162
|
+
for start, level, title, section_content in sections:
|
|
163
|
+
if len(section_content) <= self._config.max_chunk_size:
|
|
164
|
+
if len(section_content) >= self._config.min_chunk_size:
|
|
165
|
+
chunks.append(
|
|
166
|
+
Chunk(
|
|
167
|
+
document_id=document.id,
|
|
168
|
+
content=section_content,
|
|
169
|
+
start_char=start,
|
|
170
|
+
end_char=start + len(section_content),
|
|
171
|
+
metadata={
|
|
172
|
+
"source_url": document.source_url,
|
|
173
|
+
"source_path": document.source_path,
|
|
174
|
+
"title": document.title,
|
|
175
|
+
"section_title": title,
|
|
176
|
+
"heading_level": level,
|
|
177
|
+
"chunk_index": len(chunks),
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
# Split large sections with fixed chunking
|
|
183
|
+
sub_chunks = self._split_large_section(
|
|
184
|
+
document, section_content, start, title, level, len(chunks)
|
|
185
|
+
)
|
|
186
|
+
chunks.extend(sub_chunks)
|
|
187
|
+
|
|
188
|
+
# If no chunks created (no headings found), fall back to fixed chunking
|
|
189
|
+
if not chunks:
|
|
190
|
+
return self._chunk_fixed(document)
|
|
191
|
+
|
|
192
|
+
return chunks
|
|
193
|
+
|
|
194
|
+
def _split_large_section(
|
|
195
|
+
self,
|
|
196
|
+
document: Document,
|
|
197
|
+
content: str,
|
|
198
|
+
base_start: int,
|
|
199
|
+
title: str,
|
|
200
|
+
level: int,
|
|
201
|
+
chunk_index_start: int,
|
|
202
|
+
) -> list[Chunk]:
|
|
203
|
+
"""Split a large section into smaller chunks."""
|
|
204
|
+
chunks: list[Chunk] = []
|
|
205
|
+
|
|
206
|
+
# Try to split by paragraphs first
|
|
207
|
+
paragraphs = content.split("\n\n")
|
|
208
|
+
current_chunk = ""
|
|
209
|
+
current_start = base_start
|
|
210
|
+
chunk_idx = chunk_index_start
|
|
211
|
+
|
|
212
|
+
for para in paragraphs:
|
|
213
|
+
if len(current_chunk) + len(para) + 2 <= self._config.chunk_size:
|
|
214
|
+
if current_chunk:
|
|
215
|
+
current_chunk += "\n\n" + para
|
|
216
|
+
else:
|
|
217
|
+
current_chunk = para
|
|
218
|
+
else:
|
|
219
|
+
# Save current chunk
|
|
220
|
+
if len(current_chunk) >= self._config.min_chunk_size:
|
|
221
|
+
chunks.append(
|
|
222
|
+
Chunk(
|
|
223
|
+
document_id=document.id,
|
|
224
|
+
content=current_chunk,
|
|
225
|
+
start_char=current_start,
|
|
226
|
+
end_char=current_start + len(current_chunk),
|
|
227
|
+
metadata={
|
|
228
|
+
"source_url": document.source_url,
|
|
229
|
+
"source_path": document.source_path,
|
|
230
|
+
"title": document.title,
|
|
231
|
+
"section_title": title,
|
|
232
|
+
"heading_level": level,
|
|
233
|
+
"chunk_index": chunk_idx,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
chunk_idx += 1
|
|
238
|
+
|
|
239
|
+
current_start = base_start + content.find(para)
|
|
240
|
+
current_chunk = para
|
|
241
|
+
|
|
242
|
+
# Add last chunk
|
|
243
|
+
if current_chunk and len(current_chunk) >= self._config.min_chunk_size:
|
|
244
|
+
chunks.append(
|
|
245
|
+
Chunk(
|
|
246
|
+
document_id=document.id,
|
|
247
|
+
content=current_chunk,
|
|
248
|
+
start_char=current_start,
|
|
249
|
+
end_char=current_start + len(current_chunk),
|
|
250
|
+
metadata={
|
|
251
|
+
"source_url": document.source_url,
|
|
252
|
+
"source_path": document.source_path,
|
|
253
|
+
"title": document.title,
|
|
254
|
+
"section_title": title,
|
|
255
|
+
"heading_level": level,
|
|
256
|
+
"chunk_index": chunk_idx,
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return chunks
|
|
262
|
+
|
|
263
|
+
def _chunk_sentences(self, document: Document) -> list[Chunk]:
|
|
264
|
+
"""Chunk document by sentences, grouping to target size."""
|
|
265
|
+
content = document.content
|
|
266
|
+
chunks: list[Chunk] = []
|
|
267
|
+
|
|
268
|
+
# Simple sentence splitting
|
|
269
|
+
sentence_pattern = r"(?<=[.!?])\s+"
|
|
270
|
+
sentences = re.split(sentence_pattern, content)
|
|
271
|
+
|
|
272
|
+
current_chunk = ""
|
|
273
|
+
current_start = 0
|
|
274
|
+
|
|
275
|
+
for sentence in sentences:
|
|
276
|
+
if len(current_chunk) + len(sentence) + 1 <= self._config.chunk_size:
|
|
277
|
+
if current_chunk:
|
|
278
|
+
current_chunk += " " + sentence
|
|
279
|
+
else:
|
|
280
|
+
current_chunk = sentence
|
|
281
|
+
else:
|
|
282
|
+
if len(current_chunk) >= self._config.min_chunk_size:
|
|
283
|
+
chunks.append(
|
|
284
|
+
Chunk(
|
|
285
|
+
document_id=document.id,
|
|
286
|
+
content=current_chunk,
|
|
287
|
+
start_char=current_start,
|
|
288
|
+
end_char=current_start + len(current_chunk),
|
|
289
|
+
metadata={
|
|
290
|
+
"source_url": document.source_url,
|
|
291
|
+
"source_path": document.source_path,
|
|
292
|
+
"title": document.title,
|
|
293
|
+
"chunk_index": len(chunks),
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
current_start = content.find(sentence, current_start)
|
|
299
|
+
current_chunk = sentence
|
|
300
|
+
|
|
301
|
+
# Add last chunk
|
|
302
|
+
if current_chunk and len(current_chunk) >= self._config.min_chunk_size:
|
|
303
|
+
chunks.append(
|
|
304
|
+
Chunk(
|
|
305
|
+
document_id=document.id,
|
|
306
|
+
content=current_chunk,
|
|
307
|
+
start_char=current_start,
|
|
308
|
+
end_char=current_start + len(current_chunk),
|
|
309
|
+
metadata={
|
|
310
|
+
"source_url": document.source_url,
|
|
311
|
+
"source_path": document.source_path,
|
|
312
|
+
"title": document.title,
|
|
313
|
+
"chunk_index": len(chunks),
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
return chunks
|
|
319
|
+
|
|
320
|
+
def _chunk_paragraphs(self, document: Document) -> list[Chunk]:
|
|
321
|
+
"""Chunk document by paragraphs, grouping to target size."""
|
|
322
|
+
content = document.content
|
|
323
|
+
chunks: list[Chunk] = []
|
|
324
|
+
|
|
325
|
+
paragraphs = content.split("\n\n")
|
|
326
|
+
|
|
327
|
+
current_chunk = ""
|
|
328
|
+
current_start = 0
|
|
329
|
+
|
|
330
|
+
for para in paragraphs:
|
|
331
|
+
para = para.strip()
|
|
332
|
+
if not para:
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
if len(current_chunk) + len(para) + 2 <= self._config.chunk_size:
|
|
336
|
+
if current_chunk:
|
|
337
|
+
current_chunk += "\n\n" + para
|
|
338
|
+
else:
|
|
339
|
+
current_chunk = para
|
|
340
|
+
else:
|
|
341
|
+
if len(current_chunk) >= self._config.min_chunk_size:
|
|
342
|
+
chunks.append(
|
|
343
|
+
Chunk(
|
|
344
|
+
document_id=document.id,
|
|
345
|
+
content=current_chunk,
|
|
346
|
+
start_char=current_start,
|
|
347
|
+
end_char=current_start + len(current_chunk),
|
|
348
|
+
metadata={
|
|
349
|
+
"source_url": document.source_url,
|
|
350
|
+
"source_path": document.source_path,
|
|
351
|
+
"title": document.title,
|
|
352
|
+
"chunk_index": len(chunks),
|
|
353
|
+
},
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
current_start = content.find(para, current_start)
|
|
358
|
+
current_chunk = para
|
|
359
|
+
|
|
360
|
+
# Add last chunk
|
|
361
|
+
if current_chunk and len(current_chunk) >= self._config.min_chunk_size:
|
|
362
|
+
chunks.append(
|
|
363
|
+
Chunk(
|
|
364
|
+
document_id=document.id,
|
|
365
|
+
content=current_chunk,
|
|
366
|
+
start_char=current_start,
|
|
367
|
+
end_char=current_start + len(current_chunk),
|
|
368
|
+
metadata={
|
|
369
|
+
"source_url": document.source_url,
|
|
370
|
+
"source_path": document.source_path,
|
|
371
|
+
"title": document.title,
|
|
372
|
+
"chunk_index": len(chunks),
|
|
373
|
+
},
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
return chunks
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def get_chunker(config: ChunkerConfig | None = None) -> Chunker:
|
|
381
|
+
"""Get a chunker instance."""
|
|
382
|
+
return Chunker(config)
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Web crawler using Crawl4AI."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from urllib.parse import urljoin, urlparse
|
|
7
|
+
|
|
8
|
+
from docugraph.core.config import CrawlerConfig, get_config
|
|
9
|
+
from docugraph.core.models import ContentType, Document
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DocCrawler:
|
|
13
|
+
"""Web crawler for documentation sites using Crawl4AI."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: CrawlerConfig | None = None):
|
|
16
|
+
"""Initialize the crawler.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
config: Crawler configuration. If None, uses default from config.
|
|
20
|
+
"""
|
|
21
|
+
self._config = config or get_config().crawler
|
|
22
|
+
self._cache_dir = get_config().storage.data_dir / "crawl_cache"
|
|
23
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
def _get_cache_key(self, url: str) -> str:
|
|
26
|
+
"""Generate a cache key for a URL."""
|
|
27
|
+
return hashlib.sha256(url.encode()).hexdigest()[:16]
|
|
28
|
+
|
|
29
|
+
def _get_cached(self, url: str) -> Document | None:
|
|
30
|
+
"""Get cached document if available and fresh."""
|
|
31
|
+
cache_key = self._get_cache_key(url)
|
|
32
|
+
cache_file = self._cache_dir / f"{cache_key}.json"
|
|
33
|
+
|
|
34
|
+
if not cache_file.exists():
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
with open(cache_file) as f:
|
|
41
|
+
data = json.load(f)
|
|
42
|
+
|
|
43
|
+
# Check TTL. A cache entry written before timestamps carried a
|
|
44
|
+
# timezone parses as naive, so it is read as UTC rather than
|
|
45
|
+
# subtracted from an aware "now" (which raises).
|
|
46
|
+
cached_at = datetime.fromisoformat(data["cached_at"])
|
|
47
|
+
if cached_at.tzinfo is None:
|
|
48
|
+
cached_at = cached_at.replace(tzinfo=UTC)
|
|
49
|
+
age = (datetime.now(UTC) - cached_at).total_seconds()
|
|
50
|
+
if age > self._config.cache_ttl:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
return Document(**data["document"])
|
|
54
|
+
except Exception:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def _save_cache(self, url: str, document: Document) -> None:
|
|
58
|
+
"""Save document to cache."""
|
|
59
|
+
cache_key = self._get_cache_key(url)
|
|
60
|
+
cache_file = self._cache_dir / f"{cache_key}.json"
|
|
61
|
+
|
|
62
|
+
import json
|
|
63
|
+
|
|
64
|
+
data = {
|
|
65
|
+
"cached_at": datetime.now(UTC).isoformat(),
|
|
66
|
+
"document": document.model_dump(mode="json"),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
with open(cache_file, "w") as f:
|
|
70
|
+
json.dump(data, f)
|
|
71
|
+
|
|
72
|
+
async def crawl_single(
|
|
73
|
+
self,
|
|
74
|
+
url: str,
|
|
75
|
+
use_cache: bool = True,
|
|
76
|
+
) -> Document:
|
|
77
|
+
"""Crawl a single URL.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
url: The URL to crawl.
|
|
81
|
+
use_cache: Whether to use cached results.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The crawled document.
|
|
85
|
+
"""
|
|
86
|
+
# Check cache first
|
|
87
|
+
if use_cache:
|
|
88
|
+
cached = self._get_cached(url)
|
|
89
|
+
if cached:
|
|
90
|
+
return cached
|
|
91
|
+
|
|
92
|
+
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
|
|
93
|
+
from crawl4ai.content_filter_strategy import PruningContentFilter
|
|
94
|
+
from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator
|
|
95
|
+
|
|
96
|
+
browser_config = BrowserConfig(
|
|
97
|
+
headless=True,
|
|
98
|
+
verbose=False,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
crawler_config = CrawlerRunConfig(
|
|
102
|
+
markdown_generator=DefaultMarkdownGenerator(
|
|
103
|
+
content_filter=PruningContentFilter(
|
|
104
|
+
threshold=0.48,
|
|
105
|
+
threshold_type="fixed",
|
|
106
|
+
min_word_threshold=0,
|
|
107
|
+
)
|
|
108
|
+
),
|
|
109
|
+
wait_until="domcontentloaded",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async with AsyncWebCrawler(config=browser_config) as crawler:
|
|
113
|
+
result = await crawler.arun(url=url, config=crawler_config)
|
|
114
|
+
|
|
115
|
+
if not result.success:
|
|
116
|
+
raise RuntimeError(f"Failed to crawl {url}: {result.error_message}")
|
|
117
|
+
|
|
118
|
+
# Extract title from metadata or content
|
|
119
|
+
title = result.metadata.get("title") if result.metadata else None
|
|
120
|
+
if not title and result.markdown:
|
|
121
|
+
# Try to extract from first heading
|
|
122
|
+
lines = result.markdown.split("\n")
|
|
123
|
+
for line in lines:
|
|
124
|
+
if line.startswith("# "):
|
|
125
|
+
title = line[2:].strip()
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
document = Document(
|
|
129
|
+
source_url=url,
|
|
130
|
+
title=title,
|
|
131
|
+
content=result.markdown.fit_markdown
|
|
132
|
+
if hasattr(result.markdown, "fit_markdown")
|
|
133
|
+
else result.markdown,
|
|
134
|
+
content_type=ContentType.MARKDOWN,
|
|
135
|
+
metadata={
|
|
136
|
+
"raw_html_length": len(result.html) if result.html else 0,
|
|
137
|
+
"links_count": len(result.links.get("internal", [])) if result.links else 0,
|
|
138
|
+
"crawled_at": datetime.now(UTC).isoformat(),
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Cache the result
|
|
143
|
+
if use_cache:
|
|
144
|
+
self._save_cache(url, document)
|
|
145
|
+
|
|
146
|
+
return document
|
|
147
|
+
|
|
148
|
+
async def crawl_site(
|
|
149
|
+
self,
|
|
150
|
+
start_url: str,
|
|
151
|
+
max_pages: int = 100,
|
|
152
|
+
url_pattern: str | None = None,
|
|
153
|
+
use_cache: bool = True,
|
|
154
|
+
) -> list[Document]:
|
|
155
|
+
"""Crawl a documentation site starting from a URL.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
start_url: The starting URL.
|
|
159
|
+
max_pages: Maximum number of pages to crawl.
|
|
160
|
+
url_pattern: Optional regex pattern to filter URLs.
|
|
161
|
+
use_cache: Whether to use cached results.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
List of crawled documents.
|
|
165
|
+
"""
|
|
166
|
+
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
|
|
167
|
+
from crawl4ai.content_filter_strategy import PruningContentFilter
|
|
168
|
+
from crawl4ai.markdown_generation_strategy import DefaultMarkdownGenerator
|
|
169
|
+
|
|
170
|
+
browser_config = BrowserConfig(
|
|
171
|
+
headless=True,
|
|
172
|
+
verbose=False,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
crawler_config = CrawlerRunConfig(
|
|
176
|
+
markdown_generator=DefaultMarkdownGenerator(
|
|
177
|
+
content_filter=PruningContentFilter(
|
|
178
|
+
threshold=0.48,
|
|
179
|
+
threshold_type="fixed",
|
|
180
|
+
min_word_threshold=0,
|
|
181
|
+
)
|
|
182
|
+
),
|
|
183
|
+
wait_until="domcontentloaded",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Parse base domain for filtering
|
|
187
|
+
parsed_start = urlparse(start_url)
|
|
188
|
+
base_domain = parsed_start.netloc
|
|
189
|
+
parsed_start.path.rsplit("/", 1)[0] if "/" in parsed_start.path else ""
|
|
190
|
+
|
|
191
|
+
visited: set[str] = set()
|
|
192
|
+
to_visit: list[str] = [start_url]
|
|
193
|
+
documents: list[Document] = []
|
|
194
|
+
|
|
195
|
+
import re
|
|
196
|
+
|
|
197
|
+
url_regex = re.compile(url_pattern) if url_pattern else None
|
|
198
|
+
|
|
199
|
+
async with AsyncWebCrawler(config=browser_config) as crawler:
|
|
200
|
+
while to_visit and len(documents) < max_pages:
|
|
201
|
+
# Get next URL to crawl
|
|
202
|
+
url = to_visit.pop(0)
|
|
203
|
+
|
|
204
|
+
if url in visited:
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
visited.add(url)
|
|
208
|
+
|
|
209
|
+
# Check cache
|
|
210
|
+
if use_cache:
|
|
211
|
+
cached = self._get_cached(url)
|
|
212
|
+
if cached:
|
|
213
|
+
documents.append(cached)
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
result = await crawler.arun(url=url, config=crawler_config)
|
|
218
|
+
|
|
219
|
+
if not result.success:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# Extract title
|
|
223
|
+
title = result.metadata.get("title") if result.metadata else None
|
|
224
|
+
if not title and result.markdown:
|
|
225
|
+
lines = result.markdown.split("\n")
|
|
226
|
+
for line in lines:
|
|
227
|
+
if line.startswith("# "):
|
|
228
|
+
title = line[2:].strip()
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
content = (
|
|
232
|
+
result.markdown.fit_markdown
|
|
233
|
+
if hasattr(result.markdown, "fit_markdown")
|
|
234
|
+
else result.markdown
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
document = Document(
|
|
238
|
+
source_url=url,
|
|
239
|
+
title=title,
|
|
240
|
+
content=content,
|
|
241
|
+
content_type=ContentType.MARKDOWN,
|
|
242
|
+
metadata={
|
|
243
|
+
"raw_html_length": len(result.html) if result.html else 0,
|
|
244
|
+
"crawled_at": datetime.now(UTC).isoformat(),
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
documents.append(document)
|
|
249
|
+
|
|
250
|
+
if use_cache:
|
|
251
|
+
self._save_cache(url, document)
|
|
252
|
+
|
|
253
|
+
# Extract and queue internal links
|
|
254
|
+
if result.links and "internal" in result.links:
|
|
255
|
+
for link in result.links["internal"]:
|
|
256
|
+
href = link.get("href", "")
|
|
257
|
+
if not href:
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Resolve relative URLs
|
|
261
|
+
full_url = urljoin(url, href)
|
|
262
|
+
parsed = urlparse(full_url)
|
|
263
|
+
|
|
264
|
+
# Filter to same domain
|
|
265
|
+
if parsed.netloc != base_domain:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
# Filter by pattern if provided
|
|
269
|
+
if url_regex and not url_regex.search(full_url):
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
# Skip anchors and query params
|
|
273
|
+
clean_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
|
274
|
+
|
|
275
|
+
if clean_url not in visited and clean_url not in to_visit:
|
|
276
|
+
to_visit.append(clean_url)
|
|
277
|
+
|
|
278
|
+
except Exception as e:
|
|
279
|
+
# Log error but continue crawling
|
|
280
|
+
print(f"Error crawling {url}: {e}")
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
# Respect rate limit
|
|
284
|
+
await asyncio.sleep(1.0 / self._config.rate_limit)
|
|
285
|
+
|
|
286
|
+
return documents
|
|
287
|
+
|
|
288
|
+
def crawl_single_sync(self, url: str, use_cache: bool = True) -> Document:
|
|
289
|
+
"""Synchronous wrapper for crawl_single."""
|
|
290
|
+
return asyncio.run(self.crawl_single(url, use_cache))
|
|
291
|
+
|
|
292
|
+
def crawl_site_sync(
|
|
293
|
+
self,
|
|
294
|
+
start_url: str,
|
|
295
|
+
max_pages: int = 100,
|
|
296
|
+
url_pattern: str | None = None,
|
|
297
|
+
use_cache: bool = True,
|
|
298
|
+
) -> list[Document]:
|
|
299
|
+
"""Synchronous wrapper for crawl_site."""
|
|
300
|
+
return asyncio.run(self.crawl_site(start_url, max_pages, url_pattern, use_cache))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def get_crawler() -> DocCrawler:
|
|
304
|
+
"""Get the default crawler instance."""
|
|
305
|
+
return DocCrawler()
|