kpihx-web 2.2.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.
k_web/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Local-first web investigation primitives."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("kpihx-web")
7
+ except PackageNotFoundError:
8
+ __version__ = "uninstalled"
k_web/audio.py ADDED
@@ -0,0 +1,65 @@
1
+ """Free SongRec fingerprint adapter; humming is explicitly best-effort, not STT."""
2
+
3
+ import json
4
+ import math
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from .http import WebError
11
+
12
+
13
+ def identify(
14
+ path, record=None, mode="recording", duration=12.0, source="default", offset=0.0
15
+ ):
16
+ if mode not in {"recording", "hum"}:
17
+ raise WebError("Mode must be recording or hum")
18
+ if not math.isfinite(offset) or offset < 0 or (record is not None and offset):
19
+ raise WebError("Offset must be finite, nonnegative and used only with a file")
20
+ if not math.isfinite(duration) or (
21
+ record is not None and not math.isfinite(record)
22
+ ):
23
+ raise WebError("Audio durations must be finite")
24
+ if (
25
+ (path is None) == (record is None)
26
+ or duration <= 0
27
+ or (record is not None and record <= 0)
28
+ ):
29
+ raise WebError("Provide one file or --record SECONDS, with positive duration")
30
+ for command in ("ffmpeg", "songrec"):
31
+ if not shutil.which(command):
32
+ raise WebError(
33
+ f"Missing {command}; run make deps for installation guidance"
34
+ )
35
+ with tempfile.TemporaryDirectory(prefix="web-audio-") as folder:
36
+ sample = Path(folder) / "sample.wav"
37
+ args = ["ffmpeg", "-nostdin", "-v", "error"]
38
+ args += (
39
+ ["-f", "pulse", "-i", source, "-t", str(record)]
40
+ if record
41
+ else ["-ss", str(offset), "-i", str(path), "-t", str(duration)]
42
+ )
43
+ subprocess.run(
44
+ [*args, "-ac", "1", "-ar", "16000", str(sample)],
45
+ check=True,
46
+ capture_output=True,
47
+ timeout=60 + (record or 0),
48
+ )
49
+ process = subprocess.run(
50
+ ["songrec", "audio-file-to-recognized-song", str(sample)],
51
+ capture_output=True,
52
+ text=True,
53
+ timeout=30,
54
+ check=True,
55
+ )
56
+ result = json.loads(process.stdout)
57
+ return {
58
+ "requested_offset": offset,
59
+ "provider": "songrec",
60
+ "mode": mode,
61
+ "matched": bool(result.get("track")),
62
+ "result": result,
63
+ "partial": mode == "hum",
64
+ "capability": "Original-recording fingerprint, not query-by-humming; melody-only matches are not reliable",
65
+ }
k_web/backend.py ADDED
@@ -0,0 +1,277 @@
1
+ """SearXNG boundary: bounded retries, validated responses and actionable failures."""
2
+
3
+ import asyncio
4
+ import os
5
+ import sys
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from urllib.parse import urlsplit
9
+
10
+ import httpx
11
+
12
+ from .config import CONFIG
13
+ from .user_config import settings
14
+
15
+
16
+ class BackendError(RuntimeError):
17
+ """Backend failure, never interchangeable with a valid empty result set."""
18
+
19
+ def __init__(self, code: str, endpoint: str, detail: str):
20
+ self.code = code
21
+ self.endpoint = endpoint
22
+ self.detail = detail
23
+ self.action = (
24
+ "Providers are limited/blocked, not Docker. Respect cooldowns, use an available engine (-e), "
25
+ "or reuse a labelled cached snapshot. Restarting Docker/VPN is not the remedy."
26
+ if code.startswith("UPSTREAM_")
27
+ else "Run web doctor and make health in searxng; use make recover only for a confirmed local service/network failure."
28
+ )
29
+ super().__init__(f"{code}: {endpoint}: {detail}\n{self.action}")
30
+
31
+
32
+ class SurfaceSearcher:
33
+ """Preserve the search API while exposing failures and upstream degradation."""
34
+
35
+ def __init__(self, base_url=None, *, transport=None, service="surface"):
36
+ options = settings()
37
+ registry = {
38
+ "surface": {},
39
+ "onion": {
40
+ "url": "http://127.0.0.1:8082",
41
+ "socket": str(Path(CONFIG.searxng_socket).with_name("onion.sock")),
42
+ },
43
+ **options.get("search_backends", {}),
44
+ }
45
+ if service not in registry:
46
+ raise ValueError(
47
+ "Unknown search backend; configure search_backends in web/config.py"
48
+ )
49
+ profile = registry[service]
50
+ if not isinstance(profile, dict):
51
+ raise ValueError(
52
+ "search_backends entries must be dictionaries with url/socket/proxy fields"
53
+ )
54
+ self.proxy = profile.get("proxy")
55
+ self.base_url = (
56
+ base_url
57
+ or profile.get("url")
58
+ or options.get("searxng_url", CONFIG.searxng_url)
59
+ ).rstrip("/")
60
+ parsed = urlsplit(self.base_url)
61
+ if (parsed.hostname or "").endswith((".onion", ".i2p")) and not self.proxy:
62
+ raise ValueError(
63
+ "A hidden-service backend requires an explicit proxy in search_backends; no local DNS request was made"
64
+ )
65
+ if (
66
+ parsed.scheme not in {"http", "https"}
67
+ or not parsed.hostname
68
+ or parsed.username is not None
69
+ or parsed.query
70
+ or parsed.fragment
71
+ ):
72
+ raise ValueError(
73
+ "searxng_url must be an HTTP(S) base URL without credentials or query."
74
+ )
75
+ self.transport = transport
76
+ socket_path = profile.get("socket")
77
+ mode = profile.get(
78
+ "transport",
79
+ options.get("searxng_transport", "auto")
80
+ if service == "surface"
81
+ else "auto",
82
+ )
83
+ if service == "surface" and "socket" not in profile:
84
+ socket_path = options.get("searxng_socket", CONFIG.searxng_socket)
85
+ if (
86
+ self.base_url != CONFIG.searxng_url
87
+ and socket_path == CONFIG.searxng_socket
88
+ and mode != "unix"
89
+ ):
90
+ socket_path = None
91
+ if mode not in {"auto", "tcp", "unix"}:
92
+ raise BackendError(
93
+ "BACKEND_CONFIG_ERROR",
94
+ self.base_url,
95
+ "searxng_transport must be auto, unix or tcp",
96
+ )
97
+ self.socket_path = None
98
+ if (
99
+ transport is None
100
+ and base_url is None
101
+ and mode != "tcp"
102
+ and socket_path is not None
103
+ and parsed.hostname in {"localhost", "127.0.0.1", "::1"}
104
+ and (mode == "unix" or Path(socket_path).is_socket())
105
+ ):
106
+ self.socket_path = str(socket_path)
107
+ parent = Path(socket_path).parent.stat()
108
+ if parent.st_uid != os.getuid() or parent.st_mode & 0o077:
109
+ raise BackendError(
110
+ "IPC_PERMISSIONS",
111
+ self.base_url,
112
+ "Socket directory must be owned by you and mode 0700. Run make prepare in your searxng project",
113
+ )
114
+ self.channel = "unix" if self.socket_path else "tcp"
115
+ self.attempts = max(1, int(options.get("search_attempts", 3)))
116
+ self.retry_delay = max(0.0, float(options.get("search_retry_delay", 0.3)))
117
+ self.unresponsive_engines = []
118
+
119
+ async def request(self, path, params=None, timeout=30.0, headers=None):
120
+ if timeout <= 0:
121
+ raise ValueError("Search timeout must be positive.")
122
+ endpoint = f"{self.base_url}{path}"
123
+ last = "Backend connection failed"
124
+ try:
125
+ async with asyncio.timeout(timeout):
126
+ for attempt in range(self.attempts):
127
+ try:
128
+ async with httpx.AsyncClient(
129
+ timeout=httpx.Timeout(timeout, connect=min(3.0, timeout)),
130
+ trust_env=False,
131
+ proxy=self.proxy if not self.socket_path else None,
132
+ transport=self.transport
133
+ or (
134
+ httpx.AsyncHTTPTransport(uds=self.socket_path)
135
+ if self.socket_path
136
+ else None
137
+ ),
138
+ ) as client:
139
+ forwarded = dict(headers or {})
140
+ if urlsplit(self.base_url).hostname in {
141
+ "localhost",
142
+ "127.0.0.1",
143
+ "::1",
144
+ }:
145
+ forwarded.setdefault("X-Real-IP", "127.0.0.1")
146
+ response = await client.get(
147
+ endpoint, params=params, headers=forwarded
148
+ )
149
+ if response.status_code in {429, 502, 503, 504}:
150
+ last = f"Backend returned HTTP {response.status_code}"
151
+ elif response.status_code != 200:
152
+ raise BackendError(
153
+ "BACKEND_HTTP_ERROR",
154
+ endpoint,
155
+ f"HTTP {response.status_code}",
156
+ )
157
+ else:
158
+ try:
159
+ data = response.json()
160
+ except ValueError:
161
+ raise BackendError(
162
+ "BACKEND_INVALID_JSON",
163
+ endpoint,
164
+ "Expected JSON, not a proxy/login page",
165
+ ) from None
166
+ if not isinstance(data, dict):
167
+ raise BackendError(
168
+ "BACKEND_INVALID_RESPONSE",
169
+ endpoint,
170
+ "Expected a JSON object",
171
+ )
172
+ return data
173
+ except httpx.TransportError as error:
174
+ # Avoid exception text containing full query strings or custom headers.
175
+ last = f"{type(error).__name__}; connection refused/reset, DNS or network failure"
176
+ if attempt + 1 < self.attempts:
177
+ await asyncio.sleep(self.retry_delay * 2**attempt)
178
+ except TimeoutError:
179
+ raise BackendError(
180
+ "BACKEND_TIMEOUT",
181
+ endpoint,
182
+ f"Request exceeded total deadline of {timeout:g}s",
183
+ ) from None
184
+ raise BackendError("BACKEND_UNREACHABLE", endpoint, last)
185
+
186
+ async def get_engines(self):
187
+ data = await self.request("/config", timeout=10)
188
+ if not isinstance(data.get("engines"), list) or not data["engines"]:
189
+ raise BackendError(
190
+ "BACKEND_EMPTY_CATALOG",
191
+ self.base_url,
192
+ "HTTP succeeded but the engine catalogue is missing/empty",
193
+ )
194
+ return data
195
+
196
+ async def search(
197
+ self,
198
+ query,
199
+ engines=None,
200
+ categories=None,
201
+ language="all",
202
+ page=1,
203
+ safe_search=0,
204
+ time_range=None,
205
+ limit=20,
206
+ timeout=CONFIG.default_timeout_s,
207
+ custom_headers=None,
208
+ extra=None,
209
+ ):
210
+ from .models import SearchResult, extract_osint_entities
211
+
212
+ if page < 1 or limit < 0 or safe_search not in (0, 1, 2):
213
+ raise ValueError(
214
+ "Page and limit must be positive; safe-search must be 0, 1 or 2."
215
+ )
216
+ params = {
217
+ "q": query,
218
+ "format": "json",
219
+ "pageno": page,
220
+ "safesearch": safe_search,
221
+ "language": language,
222
+ }
223
+ for name, value in (
224
+ ("engines", engines),
225
+ ("categories", categories),
226
+ ("time_range", time_range),
227
+ ):
228
+ if value:
229
+ params[name] = value
230
+ if extra:
231
+ if {"q", "format", "pageno"} & extra.keys():
232
+ raise ValueError(
233
+ "Raw params cannot replace q/format; use the query argument and --format"
234
+ )
235
+ params.update(extra)
236
+ data = await self.request("/search", params, timeout, custom_headers)
237
+ raw = data.get("results")
238
+ if not isinstance(raw, list) or any(not isinstance(item, dict) for item in raw):
239
+ raise BackendError(
240
+ "BACKEND_INVALID_RESULTS",
241
+ self.base_url,
242
+ "Response has no valid results array",
243
+ )
244
+ self.unresponsive_engines = data.get("unresponsive_engines", [])
245
+ if self.unresponsive_engines:
246
+ failed = "; ".join(str(item) for item in self.unresponsive_engines)
247
+ if not raw:
248
+ raise BackendError(
249
+ "UPSTREAM_SEARCH_FAILED",
250
+ self.base_url,
251
+ f"No results and upstream engine failures: {failed}. Try another engine (-e), or retry later; do not infer absence.",
252
+ )
253
+ print(f"Warning: partial upstream coverage: {failed}", file=sys.stderr)
254
+ results = []
255
+ for row in raw[:limit] if limit else raw:
256
+ entities = extract_osint_entities(
257
+ f"{row.get('title', '')} {row.get('content', '')}"
258
+ )
259
+ results.append(
260
+ SearchResult(
261
+ title=row.get("title", ""),
262
+ url=row.get("url", ""),
263
+ snippet=row.get("content", ""),
264
+ source=f"searxng:{row.get('engine', 'unknown')}",
265
+ engine=row.get("engine", "unknown"),
266
+ category="onion"
267
+ if (urlsplit(row.get("url", "")).hostname or "").endswith(".onion")
268
+ else "surface",
269
+ timestamp=datetime.now(UTC).isoformat(),
270
+ score=float(row.get("score") or 0),
271
+ engines=row.get("engines", [row.get("engine", "unknown")]),
272
+ provider_fields=row,
273
+ extracted_emails=entities["emails"],
274
+ extracted_phones=entities["phones"],
275
+ )
276
+ )
277
+ return results