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.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- 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
|
+
)
|