deeptutor-knowledge 1.7.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.
Files changed (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,681 @@
1
+ """Async documentation-site crawler.
2
+
3
+ Fetches pages from a base URL, extracts readable text + internal links,
4
+ and follows links BFS up to a configurable depth / page count. Designed
5
+ for documentation sites (Docusaurus, MkDocs, GitBook, readthedocs, …)
6
+ where the content is in the server-rendered HTML.
7
+
8
+ Security: reuses the SSRF host-validation logic from ``web_fetch.py`` so
9
+ a malicious base URL can't be used to scan an internal network.
10
+
11
+ Output is a list of :class:`CrawledPage` objects — one per discovered
12
+ page with ``url``, ``title``, ``markdown``, and ``content_hash``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ from collections import deque
19
+ from dataclasses import dataclass, field
20
+ import hashlib
21
+ import logging
22
+ from pathlib import Path
23
+ import re
24
+ from typing import Any
25
+ from urllib.parse import quote, unquote, urldefrag, urljoin, urlparse
26
+
27
+ import httpx
28
+
29
+ from deeptutor.services.web_source.markdown import strip_leading_snapshot_provenance
30
+ from deeptutor.services.web_source.robots import CrawlAccess
31
+
32
+ # Reuse the SSRF guard and HTML extraction from web_fetch
33
+ from deeptutor.tools.web_fetch import (
34
+ DEFAULT_MAX_CHARS,
35
+ DEFAULT_TIMEOUT_S,
36
+ DEFAULT_USER_AGENT,
37
+ MAX_RESPONSE_BYTES,
38
+ _bounded_read,
39
+ _extract_readable,
40
+ _is_disallowed_host,
41
+ )
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ DEFAULT_MAX_DEPTH = 3
46
+ DEFAULT_MAX_PAGES = 200
47
+ MAX_CRAWL_DEPTH = 5
48
+ MAX_CRAWL_PAGES = DEFAULT_MAX_PAGES
49
+ DEFAULT_CONCURRENCY = 8
50
+ MAX_REDIRECTS = 5
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class CrawledPage:
55
+ """One crawled documentation page."""
56
+
57
+ url: str
58
+ title: str
59
+ markdown: str
60
+ content_hash: str
61
+ headings: list[dict] = field(default_factory=list)
62
+ language: str = ""
63
+ alternate_links: dict[str, str] = field(default_factory=dict)
64
+
65
+
66
+ @dataclass
67
+ class CrawlResult:
68
+ """Outcome of a single :func:`crawl_docs_site` invocation."""
69
+
70
+ pages: list[CrawledPage] = field(default_factory=list)
71
+ errors: list[str] = field(default_factory=list)
72
+ # Site-wide navigation extracted from sidebar elements.
73
+ navigation_links: list[dict] = field(default_factory=list)
74
+ # How navigation was obtained: "original", "inferred", or "" (none).
75
+ navigation_kind: str = ""
76
+
77
+ @property
78
+ def ok(self) -> bool:
79
+ return len(self.pages) > 0
80
+
81
+
82
+ # ── link extraction ──────────────────────────────────────────────────
83
+
84
+ _HREF_RE = re.compile(
85
+ r'<a\s+[^>]*href=["\']([^"\']+)["\']',
86
+ re.IGNORECASE,
87
+ )
88
+
89
+
90
+ def _extract_links(html: str) -> list[str]:
91
+ """Extract all href values from ``<a>`` tags in *html*."""
92
+ return _HREF_RE.findall(html)
93
+
94
+
95
+ def _normalise_link(base: str, href: str) -> str | None:
96
+ """Resolve *href* against *base*, return absolute URL or ``None``.
97
+
98
+ Returns ``None`` for non-http schemes, ``javascript:``, ``mailto:``,
99
+ and other non-page links.
100
+ """
101
+ href = href.strip()
102
+ if not href or href.startswith("#"):
103
+ return None
104
+ lower = href.lower()
105
+ if lower.startswith(("javascript:", "mailto:", "tel:", "data:", "blob:")):
106
+ return None
107
+ # Defragment
108
+ href, _frag = urldefrag(href)
109
+ if not href:
110
+ return None
111
+ absolute = urljoin(base, href)
112
+ parsed = urlparse(absolute)
113
+ if parsed.scheme.lower() not in ("http", "https"):
114
+ return None
115
+ return absolute
116
+
117
+
118
+ def _is_internal(url: str, base_host: str, base_path_prefix: str) -> bool:
119
+ """Return True if *url* belongs to the same site under the prefix."""
120
+ parsed = urlparse(url)
121
+ if parsed.hostname != base_host:
122
+ return False
123
+ path = parsed.path.rstrip("/") or "/"
124
+ prefix = base_path_prefix.rstrip("/") or "/"
125
+ # Root prefix: every path on the host is internal.
126
+ if prefix == "/":
127
+ return True
128
+ return path == prefix or path.startswith(prefix + "/")
129
+
130
+
131
+ def _to_filename(url: str, base_path_prefix: str) -> str:
132
+ """Derive a stable ``.md`` filename from a page URL.
133
+
134
+ Uses the full URL path (preserving directory structure) so pages from
135
+ different sources sharing the same ``raw/`` directory never collide:
136
+
137
+ ``/docs/getting-started/`` → ``docs/getting-started.md``
138
+ ``/zh-cn/docs/intro`` → ``zh-cn/docs/intro.md``
139
+ ``/`` → ``index.md``
140
+
141
+ ``base_path_prefix`` is accepted for signature compatibility but no
142
+ longer stripped — the full path is what makes filenames unique.
143
+ """
144
+ parsed = urlparse(url)
145
+ path = parsed.path.strip("/")
146
+ if not path:
147
+ filename = "index"
148
+ else:
149
+ # Decode one URL component at a time, then quote it back into one
150
+ # filesystem-safe component. Encoded slashes and dot segments must
151
+ # never turn into traversal below the KB's ``raw/`` directory.
152
+ segments: list[str] = []
153
+ for raw_segment in path.split("/"):
154
+ if not raw_segment:
155
+ continue
156
+ decoded = unquote(raw_segment)
157
+ safe = quote(decoded, safe="-_.~")
158
+ if decoded in {".", ".."} or safe in {".", ".."}:
159
+ safe = f"segment-{hashlib.sha256(raw_segment.encode()).hexdigest()[:10]}"
160
+ segments.append(safe or "segment")
161
+ filename = "/".join(segments) or "index"
162
+
163
+ # Query-backed documentation routes may share a path while rendering
164
+ # different pages. Keep them distinct without exposing raw query data.
165
+ if parsed.query:
166
+ filename += f"-q-{hashlib.sha256(parsed.query.encode()).hexdigest()[:10]}"
167
+ return filename + ".md"
168
+
169
+
170
+ def _source_filename(source: dict, page_url: str, base_path_prefix: str) -> str:
171
+ """Namespace a page so independent web sources cannot overwrite it."""
172
+ identity = str(source.get("url") or source.get("id") or "")
173
+ namespace = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16]
174
+ return f"_web/{namespace}/{_to_filename(page_url, base_path_prefix)}"
175
+
176
+
177
+ def _contained_path(root: Path, relative: str) -> Path | None:
178
+ """Resolve *relative* below *root*, rejecting legacy traversal metadata."""
179
+ root_resolved = root.resolve()
180
+ candidate = (root_resolved / relative).resolve()
181
+ try:
182
+ candidate.relative_to(root_resolved)
183
+ except ValueError:
184
+ return None
185
+ return candidate
186
+
187
+
188
+ # Status codes worth retrying (transient server/infrastructure issues).
189
+ _RETRY_STATUS = {429, 500, 502, 503, 504}
190
+ _MAX_RETRIES = 2
191
+
192
+
193
+ async def _fetch_page(
194
+ url: str,
195
+ *,
196
+ client: httpx.AsyncClient,
197
+ access: CrawlAccess | None = None,
198
+ ) -> tuple[str, str] | None:
199
+ """Fetch *url*, return ``(html, final_url)`` or ``None`` on failure.
200
+
201
+ Retries up to ``_MAX_RETRIES`` times on transient status codes (429,
202
+ 5xx) and network errors, with exponential backoff.
203
+ """
204
+ current_url = url
205
+ redirects = 0
206
+ attempt = 0
207
+ while True:
208
+ parsed = urlparse(current_url)
209
+ host = (parsed.hostname or "").strip()
210
+ if parsed.scheme.lower() not in ("http", "https") or not host:
211
+ logger.warning("Crawl: redirect to invalid URL %s blocked", current_url)
212
+ return None
213
+ # Validate every redirect hop before sending its request. Automatic
214
+ # redirects followed by a final-host check have already contacted a
215
+ # private target by the time they can be rejected.
216
+ if _is_disallowed_host(host):
217
+ logger.warning("Crawl: request to disallowed host %s blocked", host)
218
+ return None
219
+ if access is not None and not await access.permits_request(current_url):
220
+ logger.warning("Crawl: robots.txt blocks %s", current_url)
221
+ return None
222
+ try:
223
+ async with client.stream(
224
+ "GET",
225
+ current_url,
226
+ headers={
227
+ "User-Agent": DEFAULT_USER_AGENT,
228
+ "Accept": "text/html,application/xhtml+xml,*/*;q=0.5",
229
+ },
230
+ follow_redirects=False,
231
+ ) as response:
232
+ if response.status_code in {301, 302, 303, 307, 308}:
233
+ location = response.headers.get("location", "").strip()
234
+ if not location or redirects >= MAX_REDIRECTS:
235
+ logger.warning("Crawl: invalid or excessive redirects for %s", url)
236
+ return None
237
+ current_url = urljoin(current_url, location)
238
+ redirects += 1
239
+ attempt = 0
240
+ continue
241
+
242
+ final_url = str(response.url)
243
+ final_host = (urlparse(final_url).hostname or "").strip()
244
+ if not final_host or _is_disallowed_host(final_host):
245
+ logger.warning("Crawl: response from disallowed host %s blocked", final_host)
246
+ return None
247
+
248
+ if response.status_code in _RETRY_STATUS and attempt < _MAX_RETRIES:
249
+ backoff = 0.5 * (2**attempt)
250
+ logger.debug(
251
+ "Crawl: HTTP %d for %s, retrying in %.1fs",
252
+ response.status_code,
253
+ current_url,
254
+ backoff,
255
+ )
256
+ await asyncio.sleep(backoff)
257
+ attempt += 1
258
+ continue
259
+
260
+ if response.status_code >= 400:
261
+ logger.debug("Crawl: HTTP %d for %s", response.status_code, current_url)
262
+ return None
263
+
264
+ html = await _bounded_read(response, MAX_RESPONSE_BYTES)
265
+ return html, final_url
266
+
267
+ except httpx.HTTPError as exc:
268
+ if attempt < _MAX_RETRIES:
269
+ backoff = 0.5 * (2**attempt)
270
+ logger.debug(
271
+ "Crawl: network error for %s: %s, retrying in %.1fs",
272
+ current_url,
273
+ exc,
274
+ backoff,
275
+ )
276
+ await asyncio.sleep(backoff)
277
+ attempt += 1
278
+ continue
279
+ logger.debug("Crawl: network error for %s: %s (giving up)", current_url, exc)
280
+ return None
281
+
282
+
283
+ async def _process_page(
284
+ url: str,
285
+ depth: int,
286
+ client: httpx.AsyncClient,
287
+ sem: asyncio.Semaphore,
288
+ *,
289
+ base_host: str,
290
+ base_path_prefix: str,
291
+ max_depth: int,
292
+ access: CrawlAccess,
293
+ ) -> dict | None:
294
+ """Fetch and process a single page for concurrent crawling.
295
+
296
+ Returns a dict with ``page``, ``links``, ``nav``, ``depth``, ``final_url``
297
+ or ``None`` on fetch failure.
298
+ """
299
+ async with sem:
300
+ fetched = await _fetch_page(url, client=client, access=access)
301
+ if fetched is None:
302
+ return None
303
+ html, final_url = fetched
304
+
305
+ from deeptutor.services.web_source.html_extractor import (
306
+ extract_article_markdown,
307
+ extract_headings,
308
+ extract_navigation,
309
+ extract_page_language_and_alternates,
310
+ )
311
+
312
+ try:
313
+ title, body = extract_article_markdown(html, base_url=final_url or url)
314
+ except Exception:
315
+ title, body = _extract_readable(html)
316
+ content_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
317
+
318
+ # Extract sidebar navigation from shallow pages (cheap, most complete there).
319
+ nav = extract_navigation(html, final_url) if depth <= 1 else []
320
+
321
+ page_headings = extract_headings(body)
322
+ lang, alternates = extract_page_language_and_alternates(html, final_url or url)
323
+
324
+ if len(body) > DEFAULT_MAX_CHARS:
325
+ body = body[:DEFAULT_MAX_CHARS].rstrip() + "\n…[truncated]"
326
+
327
+ page = CrawledPage(
328
+ url=final_url,
329
+ title=title,
330
+ markdown=body,
331
+ content_hash=content_hash,
332
+ headings=page_headings,
333
+ language=lang,
334
+ alternate_links=alternates,
335
+ )
336
+
337
+ links: list[str] = []
338
+ if depth < max_depth:
339
+ for href in _extract_links(html):
340
+ link = _normalise_link(url, href)
341
+ if link and _is_internal(link, base_host, base_path_prefix):
342
+ links.append(link)
343
+
344
+ return {"page": page, "links": links, "nav": nav, "depth": depth, "final_url": final_url}
345
+
346
+
347
+ async def crawl_docs_site(
348
+ base_url: str,
349
+ *,
350
+ max_depth: int = DEFAULT_MAX_DEPTH,
351
+ max_pages: int = DEFAULT_MAX_PAGES,
352
+ timeout_s: float = DEFAULT_TIMEOUT_S,
353
+ client_factory: Any = None,
354
+ ) -> CrawlResult:
355
+ """Crawl a documentation site starting from *base_url*.
356
+
357
+ BFS traversal: fetch *base_url*, extract internal links under the same
358
+ path prefix, follow them up to *max_depth* levels deep and *max_pages*
359
+ total pages.
360
+
361
+ Returns a :class:`CrawlResult` with all successfully fetched pages.
362
+ """
363
+ result = CrawlResult()
364
+
365
+ # Validate base URL
366
+ try:
367
+ max_depth = int(max_depth)
368
+ max_pages = int(max_pages)
369
+ except (TypeError, ValueError):
370
+ result.errors.append("Crawl depth and page count must be integers")
371
+ return result
372
+ if not 0 <= max_depth <= MAX_CRAWL_DEPTH:
373
+ result.errors.append(f"Crawl depth must be between 0 and {MAX_CRAWL_DEPTH}")
374
+ return result
375
+ if not 1 <= max_pages <= MAX_CRAWL_PAGES:
376
+ result.errors.append(f"Crawl page count must be between 1 and {MAX_CRAWL_PAGES}")
377
+ return result
378
+ parsed = urlparse(base_url)
379
+ if parsed.scheme.lower() not in ("http", "https"):
380
+ result.errors.append(f"Invalid scheme: {parsed.scheme}")
381
+ return result
382
+ base_host = parsed.hostname or ""
383
+ if not base_host:
384
+ result.errors.append("Missing host in base URL")
385
+ return result
386
+ if _is_disallowed_host(base_host):
387
+ result.errors.append(f"Disallowed host: {base_host}")
388
+ return result
389
+
390
+ # Scope the crawl to the seed's directory so sibling pages count as
391
+ # internal: /intro/home seeds /intro/*, a root URL seeds the whole host.
392
+ # Pure URL-path rules (not pathlib) — Path("/intro/").parent is "/", which
393
+ # would silently widen a section seed to the entire host, and WindowsPath
394
+ # renders separators as "\", breaking the prefix entirely.
395
+ raw_path = parsed.path or "/"
396
+ if raw_path.endswith("/"):
397
+ # Directory index (or root): the directory itself is the scope.
398
+ base_path_prefix = raw_path
399
+ else:
400
+ base_path_prefix = raw_path.rsplit("/", 1)[0] or "/"
401
+
402
+ factory = client_factory or (
403
+ lambda: httpx.AsyncClient(
404
+ timeout=timeout_s,
405
+ headers={"User-Agent": DEFAULT_USER_AGENT},
406
+ max_redirects=5,
407
+ )
408
+ )
409
+
410
+ visited: set[str] = set()
411
+ queue: deque[tuple[str, int]] = deque([(base_url, 0)])
412
+ concurrency = min(DEFAULT_CONCURRENCY, max_pages)
413
+ sem = asyncio.Semaphore(concurrency)
414
+
415
+ async with factory() as client:
416
+ access = CrawlAccess(client)
417
+ policy = await access.policy_for(base_url)
418
+ if not policy.available:
419
+ result.errors.append("robots.txt unavailable; crawl blocked")
420
+ return result
421
+ if not policy.permits(base_url):
422
+ result.errors.append("robots.txt disallows the configured URL")
423
+ return result
424
+ while queue and len(visited) < max_pages:
425
+ # Dequeue a batch of URLs to process concurrently.
426
+ batch: list[tuple[str, int]] = []
427
+ while queue and len(batch) < concurrency and len(visited) < max_pages:
428
+ url, depth = queue.popleft()
429
+ if url not in visited:
430
+ visited.add(url)
431
+ batch.append((url, depth))
432
+
433
+ if not batch:
434
+ continue
435
+
436
+ # Fetch and process all pages in the batch concurrently.
437
+ tasks = [
438
+ _process_page(
439
+ url,
440
+ depth,
441
+ client,
442
+ sem,
443
+ base_host=base_host,
444
+ base_path_prefix=base_path_prefix,
445
+ max_depth=max_depth,
446
+ access=access,
447
+ )
448
+ for url, depth in batch
449
+ ]
450
+ outcomes = await asyncio.gather(*tasks, return_exceptions=True)
451
+
452
+ for outcome in outcomes:
453
+ if isinstance(outcome, BaseException) or outcome is None:
454
+ continue
455
+
456
+ # Track redirect target in visited set.
457
+ final_url = outcome["final_url"]
458
+ if final_url:
459
+ visited.add(final_url)
460
+
461
+ result.pages.append(outcome["page"])
462
+
463
+ # Update navigation (keep the result with the most links).
464
+ nav = outcome.get("nav", [])
465
+ if nav and len(nav) > len(result.navigation_links):
466
+ result.navigation_links = nav
467
+ result.navigation_kind = "original"
468
+
469
+ # Enqueue discovered links.
470
+ for link in outcome["links"]:
471
+ if link not in visited:
472
+ queue.append((link, outcome["depth"] + 1))
473
+
474
+ # If no sidebar navigation was found, infer a simple hierarchy from
475
+ # the URL paths of all crawled pages.
476
+ if not result.navigation_links and result.pages:
477
+ result.navigation_links = _infer_navigation(result.pages, base_url)
478
+ result.navigation_kind = "inferred" if result.navigation_links else ""
479
+
480
+ logger.info(
481
+ "Crawled %s: %d pages (%d errors), nav=%s (%d links)",
482
+ base_url,
483
+ len(result.pages),
484
+ len(result.errors),
485
+ result.navigation_kind,
486
+ len(result.navigation_links),
487
+ )
488
+ return result
489
+
490
+
491
+ def _infer_navigation(pages: list[CrawledPage], base_url: str) -> list[dict]:
492
+ """Build a flat navigation list from crawled page URLs.
493
+
494
+ Pages are sorted by URL path, and depth is inferred from the number
495
+ of path segments. This produces a usable (if not perfect) tree when
496
+ the site has no detectable sidebar.
497
+ """
498
+ from urllib.parse import urlparse
499
+
500
+ result: list[dict] = []
501
+ seen: set[str] = set()
502
+
503
+ for page in sorted(pages, key=lambda p: p.url):
504
+ parsed = urlparse(page.url)
505
+ path = parsed.path.rstrip("/")
506
+ if not path:
507
+ path = "/"
508
+ if path in seen:
509
+ continue
510
+ seen.add(path)
511
+
512
+ segments = [s for s in path.split("/") if s]
513
+ # Depth: index page = 0, /docs/ = 1, /docs/intro = 2, etc.
514
+ depth = len(segments)
515
+
516
+ title = page.title or segments[-1] if segments else "Home"
517
+ result.append(
518
+ {
519
+ "title": title,
520
+ "url": page.url,
521
+ "path": parsed.path,
522
+ "depth": depth if depth > 0 else 0,
523
+ }
524
+ )
525
+
526
+ return result
527
+
528
+
529
+ # ── shared crawl-diff-write pipeline ─────────────────────────────────
530
+
531
+
532
+ @dataclass
533
+ class CrawlDiff:
534
+ """Result of crawling a source, diffing against stored hashes, and writing.
535
+
536
+ :func:`sync_source` uses this to keep crawl, hash, diff, write, removal,
537
+ and navigation behavior in one bounded implementation.
538
+ """
539
+
540
+ ok: bool = True
541
+ error: str = ""
542
+ url: str = ""
543
+ page_hashes: dict[str, str] = field(default_factory=dict)
544
+ page_files: list[str] = field(default_factory=list)
545
+ page_urls: dict[str, str] = field(default_factory=dict)
546
+ pages_added: list[str] = field(default_factory=list)
547
+ pages_updated: list[str] = field(default_factory=list)
548
+ pages_unchanged: list[str] = field(default_factory=list)
549
+ pages_removed: list[str] = field(default_factory=list)
550
+ changed_paths: list[str] = field(default_factory=list)
551
+ navigation: dict = field(default_factory=dict)
552
+ pairings: list[dict] = field(default_factory=list)
553
+
554
+ @property
555
+ def page_count(self) -> int:
556
+ return len(self.page_hashes)
557
+
558
+ @property
559
+ def changed_names(self) -> list[str]:
560
+ return self.pages_added + self.pages_updated
561
+
562
+
563
+ async def crawl_and_diff(
564
+ source: dict,
565
+ raw_dir: Path,
566
+ *,
567
+ max_depth: int | None = None,
568
+ max_pages: int | None = None,
569
+ ) -> CrawlDiff:
570
+ """Crawl one source, diff against stored hashes, write changed pages.
571
+
572
+ This shared pipeline is used by ``sync_source``. It handles:
573
+
574
+ 1. Crawl the site.
575
+ 2. Build ``{filename: content_hash}`` for every page.
576
+ 3. Compare with ``source["page_hashes"]`` to compute added/updated/removed.
577
+ 4. Write new/changed pages to ``raw_dir``.
578
+ 5. Report deleted pages for the caller to remove from raw storage and index.
579
+ 6. Build a navigation manifest.
580
+
581
+ The caller is responsible for indexing and metadata persistence.
582
+ """
583
+ from deeptutor.services.web_source.navigation import build_navigation_manifest
584
+
585
+ url = source["url"]
586
+ depth = max_depth if max_depth is not None else source.get("max_depth", DEFAULT_MAX_DEPTH)
587
+ pages = max_pages if max_pages is not None else source.get("max_pages", DEFAULT_MAX_PAGES)
588
+ old_hashes: dict[str, str] = source.get("page_hashes", {})
589
+ base_path_prefix = urlparse(url).path or "/"
590
+
591
+ # 1. Crawl
592
+ try:
593
+ result = await crawl_docs_site(url, max_depth=depth, max_pages=pages)
594
+ except Exception as exc:
595
+ logger.exception("Crawl failed for %s", url)
596
+ return CrawlDiff(ok=False, error=str(exc), url=url)
597
+
598
+ if not result.ok:
599
+ msg = f"Crawl returned no pages from {url}"
600
+ if result.errors:
601
+ msg += ": " + "; ".join(result.errors)
602
+ return CrawlDiff(ok=False, error=msg, url=url)
603
+
604
+ # 2. Build current page set
605
+ current: dict[str, str] = {}
606
+ page_contents: dict[str, str] = {}
607
+ page_urls: dict[str, str] = {}
608
+ page_files: list[str] = []
609
+
610
+ for page in result.pages:
611
+ fname = _source_filename(source, page.url, base_path_prefix)
612
+ current[fname] = page.content_hash
613
+ page_contents[fname] = page.markdown
614
+ page_urls[fname] = page.url
615
+ page_files.append(fname)
616
+
617
+ # 3. Compute changes
618
+ added: list[str] = []
619
+ updated: list[str] = []
620
+ unchanged: list[str] = []
621
+ removed: list[str] = []
622
+
623
+ for fname, chash in current.items():
624
+ old = old_hashes.get(fname)
625
+ if old is None:
626
+ added.append(fname)
627
+ elif old != chash:
628
+ updated.append(fname)
629
+ else:
630
+ target = _contained_path(raw_dir, fname)
631
+ if target is not None and target.is_file():
632
+ unchanged.append(fname)
633
+ else:
634
+ # Metadata can outlive a manually removed/corrupt raw file.
635
+ # Re-stage it even though the remote content hash is stable.
636
+ updated.append(fname)
637
+
638
+ for fname in old_hashes:
639
+ if fname not in current:
640
+ removed.append(fname)
641
+
642
+ # 4. Write new/changed pages
643
+ changed_paths: list[str] = []
644
+ for fname in added + updated:
645
+ content = page_contents.get(fname, "")
646
+ full_content = strip_leading_snapshot_provenance(content)
647
+ dest = _contained_path(raw_dir, fname)
648
+ if dest is None:
649
+ return CrawlDiff(ok=False, error=f"Unsafe page filename: {fname}", url=url)
650
+ dest.parent.mkdir(parents=True, exist_ok=True)
651
+ dest.write_text(full_content, encoding="utf-8")
652
+ changed_paths.append(str(dest))
653
+
654
+ # 5. Build navigation manifest. Deletions are deliberately left to the
655
+ # caller, which must purge both the raw file and the retrieval index.
656
+ nav_manifest = build_navigation_manifest(
657
+ result.navigation_links,
658
+ result.navigation_kind,
659
+ page_urls,
660
+ )
661
+
662
+ # 6. Detect bilingual page pairings across discovered pages
663
+ from deeptutor.services.web_source.bilingual import detect_bilingual_pairings
664
+
665
+ page_file_map = {page.url: fname for page, fname in zip(result.pages, page_files)}
666
+ pairings = detect_bilingual_pairings(result.pages, page_file_map)
667
+
668
+ return CrawlDiff(
669
+ ok=True,
670
+ url=url,
671
+ page_hashes=current,
672
+ page_files=page_files,
673
+ page_urls=page_urls,
674
+ pages_added=added,
675
+ pages_updated=updated,
676
+ pages_unchanged=unchanged,
677
+ pages_removed=removed,
678
+ changed_paths=changed_paths,
679
+ navigation=nav_manifest,
680
+ pairings=pairings,
681
+ )