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 +8 -0
- k_web/audio.py +65 -0
- k_web/backend.py +277 -0
- k_web/cli.py +794 -0
- k_web/config.py +35 -0
- k_web/diagnostics.py +132 -0
- k_web/documents.py +187 -0
- k_web/help_text.py +252 -0
- k_web/http.py +316 -0
- k_web/images.py +147 -0
- k_web/maps.py +179 -0
- k_web/models.py +42 -0
- k_web/output.py +268 -0
- k_web/research.py +551 -0
- k_web/search_cache.py +44 -0
- k_web/user_config.py +56 -0
- kpihx_web-2.2.0.dist-info/METADATA +275 -0
- kpihx_web-2.2.0.dist-info/RECORD +20 -0
- kpihx_web-2.2.0.dist-info/WHEEL +4 -0
- kpihx_web-2.2.0.dist-info/entry_points.txt +3 -0
k_web/__init__.py
ADDED
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
|