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,182 @@
|
|
|
1
|
+
"""Per-crawl robots rules and host pacing for documentation sync (#1429)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from fnmatch import translate
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from urllib.parse import quote, urljoin, urlsplit
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from deeptutor.tools.web_fetch import (
|
|
16
|
+
DEFAULT_USER_AGENT,
|
|
17
|
+
MAX_RESPONSE_BYTES,
|
|
18
|
+
_bounded_read,
|
|
19
|
+
_is_disallowed_host,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
DEFAULT_REQUEST_INTERVAL_S = 0.125
|
|
23
|
+
#: A hostile robots.txt can otherwise park a crawl (and the shared per-host
|
|
24
|
+
#: pace lock) on an arbitrarily long sleep; delays beyond a minute are treated
|
|
25
|
+
#: as "no directive" and the default interval applies instead.
|
|
26
|
+
MAX_HONORED_CRAWL_DELAY_S = 60.0
|
|
27
|
+
_UNRESERVED = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalise_path(value: str) -> str:
|
|
31
|
+
# Compare UTF-8 octets while preserving escaped reserved characters.
|
|
32
|
+
value = quote(value, safe="/%:@!$&'()*+,;=-._~?")
|
|
33
|
+
return re.sub(
|
|
34
|
+
r"%([0-9a-fA-F]{2})",
|
|
35
|
+
lambda match: (
|
|
36
|
+
chr(int(match[1], 16)) if chr(int(match[1], 16)) in _UNRESERVED else match[0].upper()
|
|
37
|
+
),
|
|
38
|
+
value,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class RobotsPolicy:
|
|
44
|
+
rules: tuple[tuple[str, bool], ...] = ()
|
|
45
|
+
crawl_delay_s: float = 0.0
|
|
46
|
+
available: bool = True
|
|
47
|
+
|
|
48
|
+
def permits(self, url: str) -> bool:
|
|
49
|
+
if not self.available:
|
|
50
|
+
return False
|
|
51
|
+
parsed = urlsplit(url)
|
|
52
|
+
path = _normalise_path((parsed.path or "/") + ("?" + parsed.query if parsed.query else ""))
|
|
53
|
+
if parsed.path == "/robots.txt":
|
|
54
|
+
return True
|
|
55
|
+
matches: list[tuple[int, bool]] = []
|
|
56
|
+
for rule, allowed in self.rules:
|
|
57
|
+
anchored = rule.endswith("$")
|
|
58
|
+
pattern = rule[:-1] if anchored else rule
|
|
59
|
+
# fnmatch's atomic wildcard matching avoids exponential regex
|
|
60
|
+
# backtracking on an untrusted rule with many stars. Robots only
|
|
61
|
+
# gives '*' wildcard meaning; brackets and '?' remain literal.
|
|
62
|
+
glob = pattern.replace("[", "[[]").replace("?", "[?]")
|
|
63
|
+
expression = translate(glob + ("" if anchored else "*"))
|
|
64
|
+
if re.match(expression, path):
|
|
65
|
+
matches.append((len(rule.encode("utf-8")), allowed))
|
|
66
|
+
# Longest match wins; Allow wins ties (RFC 9309).
|
|
67
|
+
return max(matches, default=(0, True))[1]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_robots_txt(raw: str) -> RobotsPolicy:
|
|
71
|
+
groups: list[tuple[list[str], list[tuple[str, bool]], list[float]]] = []
|
|
72
|
+
agents: list[str] = []
|
|
73
|
+
rules: list[tuple[str, bool]] = []
|
|
74
|
+
delays: list[float] = []
|
|
75
|
+
has_directive = False
|
|
76
|
+
for line in raw.splitlines():
|
|
77
|
+
key, sep, value = line.split("#", 1)[0].partition(":")
|
|
78
|
+
if not sep:
|
|
79
|
+
continue
|
|
80
|
+
key, value = key.strip().lower(), value.strip()
|
|
81
|
+
if key == "user-agent":
|
|
82
|
+
if has_directive:
|
|
83
|
+
groups.append((agents, rules, delays))
|
|
84
|
+
agents, rules, delays = [], [], []
|
|
85
|
+
has_directive = False
|
|
86
|
+
if value:
|
|
87
|
+
agents.append(value.lower())
|
|
88
|
+
elif agents:
|
|
89
|
+
has_directive = True
|
|
90
|
+
if key in {"allow", "disallow"} and value.startswith("/"):
|
|
91
|
+
rules.append((_normalise_path(value), key == "allow"))
|
|
92
|
+
elif key == "crawl-delay":
|
|
93
|
+
try:
|
|
94
|
+
delay = float(value)
|
|
95
|
+
except ValueError:
|
|
96
|
+
continue
|
|
97
|
+
# Ignore absurd delays instead of honoring them: a hostile
|
|
98
|
+
# robots.txt could otherwise park every request to this host
|
|
99
|
+
# on one unbounded sleep (RFC 9309 lets crawlers cap
|
|
100
|
+
# unreasonable values).
|
|
101
|
+
if math.isfinite(delay) and 0 <= delay <= MAX_HONORED_CRAWL_DELAY_S:
|
|
102
|
+
delays.append(delay)
|
|
103
|
+
groups.append((agents, rules, delays))
|
|
104
|
+
product = DEFAULT_USER_AGENT.split("/", 1)[0].lower()
|
|
105
|
+
selected = [group for group in groups if product in group[0]]
|
|
106
|
+
selected = selected or [group for group in groups if "*" in group[0]]
|
|
107
|
+
return RobotsPolicy(
|
|
108
|
+
rules=tuple(rule for _, rules, _ in selected for rule in rules),
|
|
109
|
+
crawl_delay_s=max((delay for _, _, delays in selected for delay in delays), default=0.0),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class CrawlAccess:
|
|
114
|
+
"""Cache by origin; serialize request starts per host, including redirects."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, client: httpx.AsyncClient) -> None:
|
|
117
|
+
self.client = client
|
|
118
|
+
self._policies: dict[str, RobotsPolicy] = {}
|
|
119
|
+
self._policy_locks: dict[str, asyncio.Lock] = {}
|
|
120
|
+
self._host_locks: dict[str, asyncio.Lock] = {}
|
|
121
|
+
self._last_request: dict[str, float] = {}
|
|
122
|
+
self._intervals: dict[str, float] = {}
|
|
123
|
+
|
|
124
|
+
async def _pace(self, url: str, delay: float = 0.0) -> None:
|
|
125
|
+
host = urlsplit(url).hostname or ""
|
|
126
|
+
self._intervals[host] = max(self._intervals.get(host, DEFAULT_REQUEST_INTERVAL_S), delay)
|
|
127
|
+
async with self._host_locks.setdefault(host, asyncio.Lock()):
|
|
128
|
+
last = self._last_request.get(host)
|
|
129
|
+
if last is not None:
|
|
130
|
+
wait = self._intervals[host] - (time.monotonic() - last)
|
|
131
|
+
if wait > 0:
|
|
132
|
+
await asyncio.sleep(wait)
|
|
133
|
+
self._last_request[host] = time.monotonic()
|
|
134
|
+
|
|
135
|
+
async def _fetch_policy(self, url: str) -> RobotsPolicy:
|
|
136
|
+
# robots redirects do not recursively request another robots file, but
|
|
137
|
+
# every hop retains the same SSRF checks as page requests.
|
|
138
|
+
for _ in range(6):
|
|
139
|
+
parsed = urlsplit(url)
|
|
140
|
+
if (
|
|
141
|
+
parsed.scheme not in {"http", "https"}
|
|
142
|
+
or not parsed.hostname
|
|
143
|
+
or _is_disallowed_host(parsed.hostname)
|
|
144
|
+
):
|
|
145
|
+
return RobotsPolicy(available=False)
|
|
146
|
+
await self._pace(url)
|
|
147
|
+
try:
|
|
148
|
+
async with self.client.stream(
|
|
149
|
+
"GET",
|
|
150
|
+
url,
|
|
151
|
+
headers={"User-Agent": DEFAULT_USER_AGENT, "Accept": "text/plain,*/*"},
|
|
152
|
+
follow_redirects=False,
|
|
153
|
+
) as response:
|
|
154
|
+
if response.status_code in {301, 302, 303, 307, 308}:
|
|
155
|
+
location = response.headers.get("location", "").strip()
|
|
156
|
+
if not location:
|
|
157
|
+
return RobotsPolicy(available=False)
|
|
158
|
+
url = urljoin(url, location)
|
|
159
|
+
continue
|
|
160
|
+
if response.status_code >= 500 or response.status_code == 429:
|
|
161
|
+
return RobotsPolicy(available=False)
|
|
162
|
+
if response.status_code >= 400:
|
|
163
|
+
return RobotsPolicy()
|
|
164
|
+
return parse_robots_txt(await _bounded_read(response, MAX_RESPONSE_BYTES))
|
|
165
|
+
except httpx.HTTPError:
|
|
166
|
+
return RobotsPolicy(available=False)
|
|
167
|
+
return RobotsPolicy(available=False)
|
|
168
|
+
|
|
169
|
+
async def policy_for(self, url: str) -> RobotsPolicy:
|
|
170
|
+
parsed = urlsplit(url)
|
|
171
|
+
origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
172
|
+
async with self._policy_locks.setdefault(origin, asyncio.Lock()):
|
|
173
|
+
if origin not in self._policies:
|
|
174
|
+
self._policies[origin] = await self._fetch_policy(origin + "/robots.txt")
|
|
175
|
+
return self._policies[origin]
|
|
176
|
+
|
|
177
|
+
async def permits_request(self, url: str) -> bool:
|
|
178
|
+
policy = await self.policy_for(url)
|
|
179
|
+
if not policy.permits(url):
|
|
180
|
+
return False
|
|
181
|
+
await self._pace(url, policy.crawl_delay_s)
|
|
182
|
+
return True
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Durable background scheduler for account-scoped web sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
from deeptutor.services.web_source.repository import (
|
|
13
|
+
SQLiteWebSourceSyncRepository,
|
|
14
|
+
WebSourceSyncJob,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
WEB_SYNC_MIN_INTERVAL_HOURS = 1
|
|
20
|
+
WEB_SYNC_MAX_INTERVAL_HOURS = 168
|
|
21
|
+
WEB_SYNC_INTERVAL_HOURS = 24
|
|
22
|
+
WEB_SYNC_CHECK_SECONDS = 15
|
|
23
|
+
WEB_SYNC_LEASE_SECONDS = 60
|
|
24
|
+
WEB_SYNC_LEASE_RENEW_SECONDS = 20
|
|
25
|
+
WEB_SYNC_MAX_CONCURRENCY = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _now_ms() -> int:
|
|
29
|
+
return int(time.time() * 1000)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hours_ms(hours: int) -> int:
|
|
33
|
+
return hours * 60 * 60 * 1000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_repository() -> SQLiteWebSourceSyncRepository:
|
|
37
|
+
from deeptutor.multi_user.paths import SYSTEM_ROOT
|
|
38
|
+
|
|
39
|
+
return SQLiteWebSourceSyncRepository(SYSTEM_ROOT / "web-source-sync.sqlite")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_sync_interval(value: Any) -> int:
|
|
43
|
+
try:
|
|
44
|
+
interval = int(value)
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
return WEB_SYNC_INTERVAL_HOURS
|
|
47
|
+
return max(WEB_SYNC_MIN_INTERVAL_HOURS, min(WEB_SYNC_MAX_INTERVAL_HOURS, interval))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WebSourceSyncScheduler:
|
|
51
|
+
"""Run due web-source jobs while preserving state across restarts."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
repository: SQLiteWebSourceSyncRepository | None = None,
|
|
57
|
+
manager_factory: Callable[[], Any] | None = None,
|
|
58
|
+
check_interval_s: float = WEB_SYNC_CHECK_SECONDS,
|
|
59
|
+
max_concurrency: int = WEB_SYNC_MAX_CONCURRENCY,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._repository = repository
|
|
62
|
+
self._manager_factory = manager_factory
|
|
63
|
+
self._check_interval_s = check_interval_s
|
|
64
|
+
self._max_concurrency = max_concurrency
|
|
65
|
+
self._runner_id = f"web-sync-{uuid.uuid4().hex}"
|
|
66
|
+
self._task: asyncio.Task[None] | None = None
|
|
67
|
+
self._running = False
|
|
68
|
+
self._run_tasks: dict[tuple[str, str, str], asyncio.Task[None]] = {}
|
|
69
|
+
self._sources: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def repo(self) -> SQLiteWebSourceSyncRepository:
|
|
73
|
+
if self._repository is None:
|
|
74
|
+
self._repository = default_repository()
|
|
75
|
+
return self._repository
|
|
76
|
+
|
|
77
|
+
def _make_manager(self) -> Any:
|
|
78
|
+
if self._manager_factory is not None:
|
|
79
|
+
return self._manager_factory()
|
|
80
|
+
from deeptutor.knowledge.manager import KnowledgeBaseManager
|
|
81
|
+
from deeptutor.multi_user.knowledge_access import current_kb_base_dir
|
|
82
|
+
|
|
83
|
+
return KnowledgeBaseManager(base_dir=str(current_kb_base_dir()))
|
|
84
|
+
|
|
85
|
+
async def start(self) -> None:
|
|
86
|
+
if self._running:
|
|
87
|
+
return
|
|
88
|
+
self._running = True
|
|
89
|
+
self.repo.recover_interrupted(self._runner_id)
|
|
90
|
+
self._task = asyncio.create_task(self._loop(), name="web-source-sync:scheduler")
|
|
91
|
+
logger.info("Web source sync scheduler started")
|
|
92
|
+
|
|
93
|
+
async def stop(self) -> None:
|
|
94
|
+
self._running = False
|
|
95
|
+
if self._task and not self._task.done():
|
|
96
|
+
self._task.cancel()
|
|
97
|
+
try:
|
|
98
|
+
await self._task
|
|
99
|
+
except asyncio.CancelledError:
|
|
100
|
+
pass
|
|
101
|
+
self._task = None
|
|
102
|
+
if self._run_tasks:
|
|
103
|
+
for task in self._run_tasks.values():
|
|
104
|
+
task.cancel()
|
|
105
|
+
await asyncio.gather(*self._run_tasks.values(), return_exceptions=True)
|
|
106
|
+
self._run_tasks.clear()
|
|
107
|
+
|
|
108
|
+
async def _loop(self) -> None:
|
|
109
|
+
while self._running:
|
|
110
|
+
try:
|
|
111
|
+
await self._synchronize_sources()
|
|
112
|
+
await asyncio.to_thread(self.repo.recover_interrupted, self._runner_id)
|
|
113
|
+
self._start_due_jobs()
|
|
114
|
+
except Exception:
|
|
115
|
+
logger.exception("Web source scheduler cycle failed")
|
|
116
|
+
await asyncio.sleep(self._check_interval_s)
|
|
117
|
+
|
|
118
|
+
def _manager_for_owner(self, owner_id: str) -> Any:
|
|
119
|
+
from deeptutor.multi_user.context import reset_current_user, set_current_user
|
|
120
|
+
from deeptutor.multi_user.models import CurrentUser
|
|
121
|
+
from deeptutor.multi_user.paths import (
|
|
122
|
+
LOCAL_ADMIN_ID,
|
|
123
|
+
ensure_scope_workspace,
|
|
124
|
+
local_admin_user,
|
|
125
|
+
scope_for_user,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
user = (
|
|
129
|
+
local_admin_user()
|
|
130
|
+
if owner_id == LOCAL_ADMIN_ID
|
|
131
|
+
else CurrentUser(
|
|
132
|
+
id=owner_id,
|
|
133
|
+
username=owner_id,
|
|
134
|
+
role="user",
|
|
135
|
+
scope=scope_for_user(owner_id, is_admin=False),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
if user.scope.kind == "user":
|
|
139
|
+
ensure_scope_workspace(user.scope)
|
|
140
|
+
token = set_current_user(user)
|
|
141
|
+
try:
|
|
142
|
+
return self._make_manager()
|
|
143
|
+
finally:
|
|
144
|
+
reset_current_user(token)
|
|
145
|
+
|
|
146
|
+
async def _synchronize_sources(self) -> None:
|
|
147
|
+
from deeptutor.multi_user.paths import LOCAL_ADMIN_ID, USERS_ROOT
|
|
148
|
+
|
|
149
|
+
manager = self._manager_for_owner(LOCAL_ADMIN_ID)
|
|
150
|
+
owner_ids = [LOCAL_ADMIN_ID, *(path.name for path in USERS_ROOT.glob("*") if path.is_dir())]
|
|
151
|
+
# The first manager call above also validates the configured admin path.
|
|
152
|
+
del manager
|
|
153
|
+
source_keys: set[tuple[str, str, str]] = set()
|
|
154
|
+
sources: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
155
|
+
scanned_owner_ids: set[str] = set()
|
|
156
|
+
for owner_id in owner_ids:
|
|
157
|
+
owner_sources: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
158
|
+
try:
|
|
159
|
+
owner_manager = await asyncio.to_thread(self._manager_for_owner, owner_id)
|
|
160
|
+
for kb_name, source in owner_manager.get_all_web_sources():
|
|
161
|
+
if not source.get("enabled", True) or not source.get("auto_sync_enabled", True):
|
|
162
|
+
continue
|
|
163
|
+
source_id = str(source.get("id") or "")
|
|
164
|
+
kb_key = str(kb_name)
|
|
165
|
+
if source_id and kb_key:
|
|
166
|
+
owner_sources[owner_id, kb_key, source_id] = dict(source)
|
|
167
|
+
except Exception:
|
|
168
|
+
logger.exception("Failed to enumerate web sources for owner %s", owner_id)
|
|
169
|
+
continue
|
|
170
|
+
scanned_owner_ids.add(owner_id)
|
|
171
|
+
sources.update(owner_sources)
|
|
172
|
+
source_keys.update(owner_sources)
|
|
173
|
+
self._sources = sources
|
|
174
|
+
await asyncio.to_thread(
|
|
175
|
+
self.repo.reconcile_sources, source_keys, scanned_owner_ids=scanned_owner_ids
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _start_due_jobs(self) -> None:
|
|
179
|
+
for job in self.repo.due_jobs():
|
|
180
|
+
key = self.repo.key(job)
|
|
181
|
+
if key in self._run_tasks or key not in self._sources:
|
|
182
|
+
continue
|
|
183
|
+
if len(self._run_tasks) >= self._max_concurrency:
|
|
184
|
+
return
|
|
185
|
+
task = asyncio.create_task(self._run_job(job), name=f"web-source-sync:{key[2]}")
|
|
186
|
+
self._run_tasks[key] = task
|
|
187
|
+
task.add_done_callback(lambda _task, item=key: self._run_tasks.pop(item, None)) # type: ignore[misc]
|
|
188
|
+
|
|
189
|
+
async def _run_job(self, scheduled: WebSourceSyncJob) -> None:
|
|
190
|
+
from deeptutor.multi_user.context import reset_current_user, set_current_user
|
|
191
|
+
from deeptutor.multi_user.models import CurrentUser
|
|
192
|
+
from deeptutor.multi_user.paths import (
|
|
193
|
+
LOCAL_ADMIN_ID,
|
|
194
|
+
ensure_scope_workspace,
|
|
195
|
+
local_admin_user,
|
|
196
|
+
scope_for_user,
|
|
197
|
+
)
|
|
198
|
+
from deeptutor.services.web_source.sync import sync_source
|
|
199
|
+
|
|
200
|
+
key = self.repo.key(scheduled)
|
|
201
|
+
lease_until = _now_ms() + WEB_SYNC_LEASE_SECONDS * 1000
|
|
202
|
+
claimed = await asyncio.to_thread(
|
|
203
|
+
self.repo.claim,
|
|
204
|
+
scheduled,
|
|
205
|
+
runner_id=f"{self._runner_id}:{uuid.uuid4().hex}",
|
|
206
|
+
lease_until_ms=lease_until,
|
|
207
|
+
)
|
|
208
|
+
if claimed is None:
|
|
209
|
+
return
|
|
210
|
+
source = self._sources.get(key)
|
|
211
|
+
if source is None:
|
|
212
|
+
# A scan may have failed after this job was queued. Keep the job
|
|
213
|
+
# retryable until its owner's inventory is read successfully.
|
|
214
|
+
self.repo.mark_interrupted(claimed)
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
user = (
|
|
218
|
+
local_admin_user()
|
|
219
|
+
if claimed.owner_id == LOCAL_ADMIN_ID
|
|
220
|
+
else CurrentUser(
|
|
221
|
+
id=claimed.owner_id,
|
|
222
|
+
username=claimed.owner_id,
|
|
223
|
+
role="user",
|
|
224
|
+
scope=scope_for_user(claimed.owner_id, is_admin=False),
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
if user.scope.kind == "user":
|
|
228
|
+
ensure_scope_workspace(user.scope)
|
|
229
|
+
token = set_current_user(user)
|
|
230
|
+
owner_task = asyncio.current_task()
|
|
231
|
+
assert owner_task is not None
|
|
232
|
+
lease_task = asyncio.create_task(
|
|
233
|
+
self._renew_lease(claimed, owner_task), name=f"web-source-sync:lease:{key[2]}"
|
|
234
|
+
)
|
|
235
|
+
try:
|
|
236
|
+
current = await asyncio.to_thread(self.repo.get, key)
|
|
237
|
+
if current is not None and current.cancel_requested:
|
|
238
|
+
self.repo.mark_cancelled(current)
|
|
239
|
+
return
|
|
240
|
+
result = await sync_source(
|
|
241
|
+
kb_name=claimed.kb_name,
|
|
242
|
+
source=source,
|
|
243
|
+
base_dir=str(self._make_manager().base_dir),
|
|
244
|
+
max_depth=source.get("max_depth"),
|
|
245
|
+
max_pages=source.get("max_pages"),
|
|
246
|
+
)
|
|
247
|
+
current = await asyncio.to_thread(self.repo.get, key)
|
|
248
|
+
if current is not None and current.cancel_requested:
|
|
249
|
+
self.repo.mark_cancelled(current)
|
|
250
|
+
return
|
|
251
|
+
if result.ok:
|
|
252
|
+
interval = normalize_sync_interval(source.get("sync_interval_hours"))
|
|
253
|
+
self.repo.mark_success(claimed, _now_ms() + _hours_ms(interval))
|
|
254
|
+
else:
|
|
255
|
+
self._mark_failure(claimed, source, result.error or "Web synchronization failed")
|
|
256
|
+
except asyncio.CancelledError:
|
|
257
|
+
current = self.repo.get(key)
|
|
258
|
+
if current is not None and current.cancel_requested:
|
|
259
|
+
self.repo.mark_cancelled(claimed, _now_ms())
|
|
260
|
+
else:
|
|
261
|
+
self.repo.mark_interrupted(claimed)
|
|
262
|
+
raise
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
logger.exception("Scheduled web source sync failed for %s", source.get("url"))
|
|
265
|
+
self._mark_failure(claimed, source, str(exc))
|
|
266
|
+
finally:
|
|
267
|
+
lease_task.cancel()
|
|
268
|
+
await asyncio.gather(lease_task, return_exceptions=True)
|
|
269
|
+
reset_current_user(token)
|
|
270
|
+
|
|
271
|
+
async def _renew_lease(self, job: WebSourceSyncJob, owner_task: asyncio.Task[None]) -> None:
|
|
272
|
+
while True:
|
|
273
|
+
await asyncio.sleep(WEB_SYNC_LEASE_RENEW_SECONDS)
|
|
274
|
+
renewed = await asyncio.to_thread(
|
|
275
|
+
self.repo.renew_lease, job, _now_ms() + WEB_SYNC_LEASE_SECONDS * 1000
|
|
276
|
+
)
|
|
277
|
+
if not renewed:
|
|
278
|
+
owner_task.cancel()
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
def _mark_failure(
|
|
282
|
+
self,
|
|
283
|
+
job: WebSourceSyncJob,
|
|
284
|
+
source: dict[str, Any],
|
|
285
|
+
error: str,
|
|
286
|
+
) -> None:
|
|
287
|
+
interval = normalize_sync_interval(source.get("sync_interval_hours"))
|
|
288
|
+
backoff = min(interval, WEB_SYNC_MIN_INTERVAL_HOURS * max(1, job.attempt + 1))
|
|
289
|
+
self.repo.mark_failure(
|
|
290
|
+
job,
|
|
291
|
+
error=error,
|
|
292
|
+
next_run_at_ms=_now_ms() + _hours_ms(backoff),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
async def request_cancel(self, owner_id: str, kb_name: str, source_id: str) -> bool:
|
|
296
|
+
key = owner_id, kb_name, source_id
|
|
297
|
+
if not await asyncio.to_thread(self.repo.request_cancel, key):
|
|
298
|
+
return False
|
|
299
|
+
task = self._run_tasks.get(key)
|
|
300
|
+
if task is not None and not task.done():
|
|
301
|
+
task.cancel()
|
|
302
|
+
return True
|
|
303
|
+
|
|
304
|
+
async def retry(self, owner_id: str, kb_name: str, source_id: str) -> bool:
|
|
305
|
+
job = await asyncio.to_thread(self.repo.retry, (owner_id, kb_name, source_id))
|
|
306
|
+
return job is not None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
_scheduler: WebSourceSyncScheduler | None = None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_web_source_sync_scheduler() -> WebSourceSyncScheduler:
|
|
313
|
+
global _scheduler
|
|
314
|
+
if _scheduler is None:
|
|
315
|
+
_scheduler = WebSourceSyncScheduler()
|
|
316
|
+
return _scheduler
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def start_web_source_sync_scheduler() -> None:
|
|
320
|
+
await get_web_source_sync_scheduler().start()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def stop_web_source_sync_scheduler() -> None:
|
|
324
|
+
await get_web_source_sync_scheduler().stop()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
__all__ = [
|
|
328
|
+
"WEB_SYNC_INTERVAL_HOURS",
|
|
329
|
+
"WEB_SYNC_MAX_INTERVAL_HOURS",
|
|
330
|
+
"WEB_SYNC_MIN_INTERVAL_HOURS",
|
|
331
|
+
"WebSourceSyncScheduler",
|
|
332
|
+
"get_web_source_sync_scheduler",
|
|
333
|
+
"normalize_sync_interval",
|
|
334
|
+
"start_web_source_sync_scheduler",
|
|
335
|
+
"stop_web_source_sync_scheduler",
|
|
336
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Safe, local image assets for captured web Markdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urljoin, urlsplit
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from deeptutor.tools.web_fetch import (
|
|
17
|
+
ALLOWED_SCHEMES,
|
|
18
|
+
DEFAULT_TIMEOUT_S,
|
|
19
|
+
DEFAULT_USER_AGENT,
|
|
20
|
+
MAX_REDIRECTS,
|
|
21
|
+
_is_disallowed_host,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
MAX_SNAPSHOT_IMAGES = 24
|
|
27
|
+
MAX_SNAPSHOT_IMAGE_BYTES = 8 * 1024 * 1024
|
|
28
|
+
_IMAGE = re.compile(
|
|
29
|
+
r"!\[([^\]]*)\]\(\s*(https?://[^\s)]+)(?:\s+[\"'][^)]*[\"'])?\s*\)",
|
|
30
|
+
re.IGNORECASE,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class SnapshotAsset:
|
|
36
|
+
data: bytes
|
|
37
|
+
mime: str
|
|
38
|
+
extension: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ImageFetcher = Callable[[str], Awaitable[SnapshotAsset | None]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def localize_snapshot_images(
|
|
45
|
+
markdown: str,
|
|
46
|
+
material_id: str,
|
|
47
|
+
*,
|
|
48
|
+
fetcher: ImageFetcher | None = None,
|
|
49
|
+
) -> tuple[str, dict[str, bytes]]:
|
|
50
|
+
"""Cache bounded raster images and replace hotlinks with local API URLs."""
|
|
51
|
+
matches = list(_IMAGE.finditer(markdown))
|
|
52
|
+
urls = list(dict.fromkeys(match.group(2) for match in matches))[:MAX_SNAPSHOT_IMAGES]
|
|
53
|
+
if not urls:
|
|
54
|
+
return markdown, {}
|
|
55
|
+
|
|
56
|
+
resolved_fetcher = fetcher or fetch_snapshot_image
|
|
57
|
+
semaphore = asyncio.Semaphore(4)
|
|
58
|
+
|
|
59
|
+
async def load(url: str) -> tuple[str, SnapshotAsset | None]:
|
|
60
|
+
try:
|
|
61
|
+
async with semaphore:
|
|
62
|
+
return url, await resolved_fetcher(url)
|
|
63
|
+
except Exception:
|
|
64
|
+
logger.info("Snapshot image could not be localized: %s", url, exc_info=True)
|
|
65
|
+
return url, None
|
|
66
|
+
|
|
67
|
+
fetched = dict(await asyncio.gather(*(load(url) for url in urls)))
|
|
68
|
+
assets: dict[str, bytes] = {}
|
|
69
|
+
replacements: dict[str, str | None] = {}
|
|
70
|
+
for url, asset in fetched.items():
|
|
71
|
+
if asset is None:
|
|
72
|
+
replacements[url] = None
|
|
73
|
+
continue
|
|
74
|
+
digest = hashlib.sha256(url.encode("utf-8") + asset.data).hexdigest()[:20]
|
|
75
|
+
name = f"{digest}.{asset.extension}"
|
|
76
|
+
assets[name] = asset.data
|
|
77
|
+
replacements[url] = f"/api/reading/materials/{material_id}/assets/{name}"
|
|
78
|
+
|
|
79
|
+
def replace(match: re.Match[str]) -> str:
|
|
80
|
+
alt, url = match.group(1), match.group(2)
|
|
81
|
+
local = replacements.get(url)
|
|
82
|
+
if local:
|
|
83
|
+
return f""
|
|
84
|
+
label = alt.strip() or "image"
|
|
85
|
+
return f"*Image unavailable: {label}*"
|
|
86
|
+
|
|
87
|
+
return _IMAGE.sub(replace, markdown), assets
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def fetch_snapshot_image(
|
|
91
|
+
url: str,
|
|
92
|
+
*,
|
|
93
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
94
|
+
client_factory: Any = None,
|
|
95
|
+
host_validator: Any = None,
|
|
96
|
+
) -> SnapshotAsset | None:
|
|
97
|
+
"""Fetch one public raster image with redirect, MIME, and size guards."""
|
|
98
|
+
current_url = str(url or "").strip()
|
|
99
|
+
validator = host_validator or _is_disallowed_host
|
|
100
|
+
factory = client_factory or _default_client_factory
|
|
101
|
+
try:
|
|
102
|
+
async with factory(timeout=timeout_s) as client:
|
|
103
|
+
for hop in range(MAX_REDIRECTS + 1):
|
|
104
|
+
parsed = urlsplit(current_url)
|
|
105
|
+
host = (parsed.hostname or "").strip()
|
|
106
|
+
if parsed.scheme.lower() not in ALLOWED_SCHEMES or not host:
|
|
107
|
+
return None
|
|
108
|
+
if validator(host):
|
|
109
|
+
return None
|
|
110
|
+
async with client.stream(
|
|
111
|
+
"GET",
|
|
112
|
+
current_url,
|
|
113
|
+
headers={
|
|
114
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
115
|
+
"Accept": "image/png,image/jpeg,image/gif,image/webp;q=0.9",
|
|
116
|
+
},
|
|
117
|
+
follow_redirects=False,
|
|
118
|
+
) as response:
|
|
119
|
+
location = response.headers.get("location", "")
|
|
120
|
+
if response.status_code in {301, 302, 303, 307, 308} and location:
|
|
121
|
+
if hop >= MAX_REDIRECTS:
|
|
122
|
+
return None
|
|
123
|
+
current_url = urljoin(current_url, location)
|
|
124
|
+
continue
|
|
125
|
+
if response.status_code >= 400:
|
|
126
|
+
return None
|
|
127
|
+
data = await _bounded_image_read(response)
|
|
128
|
+
return _sniff_raster(data)
|
|
129
|
+
except httpx.HTTPError:
|
|
130
|
+
return None
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _default_client_factory(*, timeout: float) -> httpx.AsyncClient:
|
|
135
|
+
return httpx.AsyncClient(timeout=timeout, max_redirects=MAX_REDIRECTS)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _bounded_image_read(response: httpx.Response) -> bytes:
|
|
139
|
+
data = bytearray()
|
|
140
|
+
async for chunk in response.aiter_bytes():
|
|
141
|
+
data.extend(chunk)
|
|
142
|
+
if len(data) > MAX_SNAPSHOT_IMAGE_BYTES:
|
|
143
|
+
return b""
|
|
144
|
+
return bytes(data)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _sniff_raster(data: bytes) -> SnapshotAsset | None:
|
|
148
|
+
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
149
|
+
return SnapshotAsset(data, "image/png", "png")
|
|
150
|
+
if data.startswith(b"\xff\xd8\xff"):
|
|
151
|
+
return SnapshotAsset(data, "image/jpeg", "jpg")
|
|
152
|
+
if data.startswith((b"GIF87a", b"GIF89a")):
|
|
153
|
+
return SnapshotAsset(data, "image/gif", "gif")
|
|
154
|
+
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
155
|
+
return SnapshotAsset(data, "image/webp", "webp")
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def snapshot_asset_mime(data: bytes) -> str | None:
|
|
160
|
+
asset = _sniff_raster(data)
|
|
161
|
+
return asset.mime if asset else None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
__all__ = [
|
|
165
|
+
"MAX_SNAPSHOT_IMAGE_BYTES",
|
|
166
|
+
"MAX_SNAPSHOT_IMAGES",
|
|
167
|
+
"SnapshotAsset",
|
|
168
|
+
"fetch_snapshot_image",
|
|
169
|
+
"localize_snapshot_images",
|
|
170
|
+
"snapshot_asset_mime",
|
|
171
|
+
]
|