Files
search/search/api.py
T
2026-10-17 18:32:59 +02:00

742 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
JonasSearch Suchmaschine – HTTP-API + Weboberfläche (api.py)
Läuft auf PORT = Vorschlagsverfeinerer (8082) + 1 = 8083.
Endpunkte:
GET / Weboberfläche (Suchseite)
GET /api/search Volltextsuche (q, page, per_page, site, tld,
after, before, sort, include_errors,
include_non_html, snippets)
GET /api/images Bildersuche (q, page, per_page, site, tld,
after, before, sort)
GET /api/suggest Autocomplete (q, limit)
GET /api/page einzelne Seite (id oder url)
GET /api/stats Index-Statistiken
GET /health Healthcheck (DB-Status)
Port-Auflösung: --port > JONASSEARCH_API_PORT (.env) > 8083.
Der systemd-Dienst (search/jonassearch-engine.service) pinnt
JONASSEARCH_API_PORT=8083 NACH dem EnvironmentFile und überschreibt
damit einen eventuell abweichenden Wert aus .env.
"""
import logging
import os
import sys
import traceback
from pathlib import Path
from dotenv import load_dotenv
# .env laden BEVOR search.engine importiert wird (liest die
# JONASSEARCH_*-Variablen bei Importzeit): erst Working Directory,
# dann .env neben dem Projektstamm – wie launcher.py.
for _dotenv_path in (None, Path(__file__).resolve().parent.parent / ".env"):
try:
load_dotenv(_dotenv_path)
except OSError:
pass
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import psycopg2 # noqa: E402
from flask import Flask, jsonify, render_template_string, request # noqa: E402
try: # Paketkontext (python3 -m search.api)
from . import engine # noqa: E402
except ImportError: # Direktstart: python3 search/api.py
from search import engine # noqa: E402
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [search-api] %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("jonassearch.search-api")
DEFAULT_PORT = 8083
app = Flask(__name__)
# ============================================================
# HELFER
# ============================================================
def _truthy(value):
return str(value or "").strip().lower() in ("1", "true", "yes", "on", "ja")
def _page_param(name, default):
raw = request.args.get(name)
if raw is None or str(raw).strip() == "":
return default
try:
return int(raw)
except ValueError:
raise engine.QueryError(f"{name} muss eine Zahl sein.")
def _search_params():
"""Query-Parameter der Request -> engine.search()-Argumente."""
q = request.args.get("q", "").strip()
if not q:
raise engine.QueryError(
"Parameter 'q' (Suchbegriffe) fehlt oder ist leer."
)
return dict(
query=q,
page=_page_param("page", 1),
per_page=_page_param("per_page", None),
sites=request.args.getlist("site") + request.args.getlist("sites"),
tlds=request.args.getlist("tld") + request.args.getlist("tlds"),
after=request.args.get("after"),
before=request.args.get("before"),
sort=request.args.get("sort", "relevance"),
include_errors=_truthy(request.args.get("include_errors")),
include_non_html=_truthy(request.args.get("include_non_html")),
with_snippets=not _truthy(request.args.get("no_snippets")),
)
# ============================================================
# API-ENDPUNKTE
# ============================================================
@app.route("/api/search")
def api_search():
try:
result = engine.search(**_search_params())
return jsonify(result)
except engine.QueryError as exc:
return jsonify({"error": str(exc)}), 400
except engine.SearchTimeout as exc:
return jsonify({"error": str(exc)}), 504
except psycopg2.OperationalError as exc:
log.error("Datenbank nicht erreichbar: %s", exc)
return jsonify({"error": "Datenbank nicht erreichbar."}), 503
except engine.SearchEngineError as exc:
log.error("Suchfehler: %s", exc)
return jsonify({"error": str(exc)}), 500
except Exception:
log.error("Unerwarteter Fehler in /api/search:\n%s",
traceback.format_exc())
return jsonify({"error": "Interner Fehler."}), 500
@app.route("/api/images")
def api_images():
try:
q = request.args.get("q", "").strip()
if not q:
raise engine.QueryError(
"Parameter 'q' (Suchbegriffe) fehlt oder ist leer."
)
result = engine.search_images(
query=q,
page=_page_param("page", 1),
per_page=_page_param("per_page", None),
sites=request.args.getlist("site") + request.args.getlist("sites"),
tlds=request.args.getlist("tld") + request.args.getlist("tlds"),
after=request.args.get("after"),
before=request.args.get("before"),
sort=request.args.get("sort", "relevance"),
)
return jsonify(result)
except engine.QueryError as exc:
return jsonify({"error": str(exc)}), 400
except engine.SearchTimeout as exc:
return jsonify({"error": str(exc)}), 504
except psycopg2.OperationalError as exc:
log.error("Datenbank nicht erreichbar: %s", exc)
return jsonify({"error": "Datenbank nicht erreichbar."}), 503
except engine.SearchEngineError as exc:
log.error("Bilder-Suchfehler: %s", exc)
return jsonify({"error": str(exc)}), 500
except Exception:
log.error("Unerwarteter Fehler in /api/images:\n%s",
traceback.format_exc())
return jsonify({"error": "Interner Fehler."}), 500
@app.route("/api/suggest")
def api_suggest():
try:
limit = _page_param("limit", engine.MAX_SUGGEST)
return jsonify({
"query": request.args.get("q", ""),
"suggestions": engine.suggest(request.args.get("q", ""), limit),
})
except engine.QueryError as exc:
return jsonify({"error": str(exc)}), 400
except psycopg2.OperationalError:
# Vorschläge sind optional: still leere Liste liefern.
return jsonify({"query": request.args.get("q", ""),
"suggestions": []})
@app.route("/api/page")
def api_page():
try:
page_id = request.args.get("id")
url = request.args.get("url")
page = engine.get_page(
page_id=page_id,
url=url,
max_content_chars=_page_param(
"max_content_chars", engine.PAGE_CONTENT_CHARS
),
)
if page is None:
return jsonify({"error": "Seite nicht gefunden."}), 404
return jsonify(page)
except engine.QueryError as exc:
return jsonify({"error": str(exc)}), 400
except psycopg2.OperationalError as exc:
log.error("Datenbank nicht erreichbar: %s", exc)
return jsonify({"error": "Datenbank nicht erreichbar."}), 503
@app.route("/api/stats")
def api_stats():
try:
return jsonify(engine.stats())
except psycopg2.OperationalError as exc:
log.error("Datenbank nicht erreichbar: %s", exc)
return jsonify({"error": "Datenbank nicht erreichbar."}), 503
@app.route("/health")
def health():
status = engine.health()
return jsonify(status), (200 if status.get("ok") else 503)
# ============================================================
# WEBOBERFLÄCHE
# ============================================================
HTML = r"""
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JonasSearch – Websuche</title>
<style>
:root {
--bg: #0f1115;
--card: #1b1e24;
--border: #2c313a;
--text: #e8eaed;
--muted: #9aa0a6;
--accent: #4f8cff;
--mark: #f5a623;
--link: #8ab4f8;
}
html[data-theme="light"] {
--bg: #f4f6fa;
--card: #ffffff;
--border: #d8dde5;
--text: #1a1d23;
--muted: #6b7280;
--accent: #2f6fe4;
--mark: #b45309;
--link: #1a4fc0;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Arial, sans-serif;
}
.wrap { max-width: 860px; margin: 0 auto; padding: 40px 18px 60px; }
header {
display: flex; align-items: baseline; gap: 12px; margin-bottom: 26px;
}
h1 { margin: 0; font-size: 2rem; letter-spacing: .3px; }
h1 a { color: var(--text); text-decoration: none; }
h1 a:hover { color: var(--accent); }
.badge {
background: var(--card); border: 1px solid var(--border);
color: var(--muted); font-size: .78rem; padding: 4px 10px;
border-radius: 999px; white-space: nowrap;
}
.searchbar { position: relative; display: flex; gap: 10px; }
#q {
flex: 1; font-size: 1.05rem; padding: 13px 16px; color: var(--text);
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; outline: none;
}
#q:focus { border-color: var(--accent); }
button {
background: var(--accent); border: none; color: #fff;
font-size: .95rem; font-weight: 600; padding: 12px 22px;
border-radius: 12px; cursor: pointer;
}
button:hover { filter: brightness(1.1); }
button:disabled { opacity: .45; cursor: default; filter: none; }
.filters { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
.filters input, .filters select {
background: var(--card); color: var(--text);
border: 1px solid var(--border); border-radius: 999px;
font-size: .82rem; padding: 7px 14px; outline: none;
}
.filters input:focus, .filters select:focus { border-color: var(--accent); }
.tabs { display: flex; gap: 4px; margin: 18px 0 14px;
border-bottom: 1px solid var(--border); }
.tabs button { background: none; color: var(--muted); border: none;
border-bottom: 2px solid transparent; border-radius: 0;
padding: 9px 16px; font-size: .95rem; font-weight: 600; }
.tabs button:hover { color: var(--text); filter: none; }
.tabs button.active { color: var(--accent);
border-bottom-color: var(--accent); }
.img-grid { display: grid; gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
.img-card { position: relative; display: block; aspect-ratio: 1 / 1;
overflow: hidden; border-radius: 12px; background: var(--card);
border: 1px solid var(--border); text-decoration: none;
color: var(--text); }
.img-card img { width: 100%; height: 100%; object-fit: cover;
display: block; background: var(--card); }
.img-card .caption { position: absolute; left: 0; right: 0; bottom: 0;
padding: 22px 10px 8px; font-size: .74rem;
line-height: 1.3; color: #fff; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
background: linear-gradient(transparent,
rgba(0, 0, 0, .8)); }
.suggest {
position: absolute; top: 54px; left: 0; right: 90px; z-index: 20;
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden; box-shadow: 0 10px 30px rgba(0,0,0,.35);
}
.suggest.hidden { display: none; }
.suggest-item {
padding: 9px 14px; cursor: pointer; font-size: .92rem;
display: flex; justify-content: space-between; gap: 12px;
}
.suggest-item:hover { background: rgba(128,128,128,.12); }
.suggest-item .dom { color: var(--muted); font-size: .78rem; }
.info { color: var(--muted); font-size: .85rem; margin: 20px 0 14px; }
.info.error { color: #ff6b6b; }
.result { margin-bottom: 26px; }
.result .title {
display: block; color: var(--link); font-size: 1.08rem;
font-weight: 600; text-decoration: none; margin-bottom: 2px;
overflow-wrap: anywhere;
}
.result .title:hover { text-decoration: underline; }
.result .url { color: #2ecc71; font-size: .8rem; margin-bottom: 4px;
overflow-wrap: anywhere; }
html[data-theme="light"] .result .url { color: #157a3c; }
.result .snippet { font-size: .9rem; line-height: 1.55;
color: var(--text); overflow-wrap: anywhere; }
.result .meta { color: var(--muted); font-size: .76rem; margin-top: 4px; }
mark { background: none; color: var(--mark); font-weight: 700;
padding: 0; }
.pager { display: flex; align-items: center; gap: 16px; margin: 34px 0 0; }
.pager .pageinfo { color: var(--muted); font-size: .85rem; }
.pager button { background: var(--card); border: 1px solid var(--border);
color: var(--text); padding: 8px 16px; }
.pager button:hover { border-color: var(--accent); }
.hint { color: var(--muted); margin-top: 40px; font-size: .9rem;
line-height: 1.7; }
.hint code { background: var(--card); border: 1px solid var(--border);
border-radius: 6px; padding: 1px 7px; font-size: .82rem; }
footer { color: var(--muted); font-size: .76rem; margin-top: 60px;
border-top: 1px solid var(--border); padding-top: 14px; }
@media (max-width: 600px) {
.wrap { padding: 24px 12px 40px; }
.searchbar { flex-direction: column; }
.suggest { right: 0; top: 106px; }
.img-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1><a href="/">JonasSearch</a></h1>
<span class="badge" id="stats-badge" style="display:none"></span>
</header>
<form id="f" autocomplete="off">
<div class="searchbar">
<input id="q" type="search" placeholder="Suchbegriffe – &quot;exakte Phrase&quot;, OR, -ausschluss" autofocus>
<button type="submit">Suchen</button>
<div id="suggest" class="suggest hidden"></div>
</div>
<div class="filters">
<input id="site" placeholder="site: example.com" size="18">
<select id="range">
<option value="">Beliebige Zeit</option>
<option value="24h">Letzte 24 Stunden</option>
<option value="7d">Letzte 7 Tage</option>
<option value="30d">Letzte 30 Tage</option>
<option value="365d">Letztes Jahr</option>
</select>
<select id="sort">
<option value="relevance">Relevanz</option>
<option value="freshness">Aktualität</option>
</select>
</div>
</form>
<div class="tabs" role="tablist">
<button type="button" id="tab-web" class="active" role="tab">Web</button>
<button type="button" id="tab-images" role="tab">Bilder</button>
</div>
<div id="info" class="info"></div>
<div id="results"></div>
<div id="pager" class="pager" style="display:none">
<button id="prev">&lsaquo; Zurück</button>
<span id="pageinfo" class="pageinfo"></span>
<button id="next">Weiter &rsaquo;</button>
</div>
<div class="hint" id="hint">
Beispiele: <code>postgreSQL volltextsuche</code> &middot;
<code>"exacte phrase"</code> &middot;
<code>crawler -python</code> &middot;
<code>postgres OR sqlite</code>
</div>
<footer>JonasSearch Suchmaschine &middot; PostgreSQL-Volltextsuche
über gecrawlte Webseiten &middot; Port 8083</footer>
</div>
<script>
'use strict';
const $ = id => document.getElementById(id);
const state = { q: "", site: "", range: "", sort: "relevance",
page: 1, view: "web" };
let suggestTimer = null;
function esc(s) {
return String(s == null ? "" : s)
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
function fmtInt(n) { return Number(n).toLocaleString("de-DE"); }
function prettyUrl(u) {
let s = String(u || "").replace(/^https?:\/\//, "");
if (s.length > 100) s = s.slice(0, 100) + "…";
return s;
}
function fmtDate(iso) {
if (!iso) return "–";
const d = new Date(iso);
return isNaN(d) ? "–"
: d.toLocaleDateString("de-DE",
{ day: "2-digit", month: "2-digit", year: "numeric" });
}
function fromHash() {
const p = new URLSearchParams(location.hash.slice(1));
state.q = p.get("q") || "";
state.site = p.get("site") || "";
state.range = p.get("after") || "";
state.sort = p.get("sort") || "relevance";
state.page = Math.max(1, parseInt(p.get("page") || "1", 10) || 1);
state.view = p.get("view") === "images" ? "images" : "web";
$("q").value = state.q;
$("site").value = state.site;
$("range").value = state.range;
$("sort").value = state.sort;
updateTabs();
}
function updateTabs() {
$("tab-web").classList.toggle("active", state.view !== "images");
$("tab-images").classList.toggle("active", state.view === "images");
}
function toHash() {
const p = new URLSearchParams();
if (state.q) p.set("q", state.q);
if (state.site) p.set("site", state.site);
if (state.range) p.set("after", state.range);
if (state.sort && state.sort !== "relevance") p.set("sort", state.sort);
if (state.view === "images") p.set("view", "images");
if (state.page > 1) p.set("page", String(state.page));
history.replaceState(null, "", p.toString() ? "#" + p.toString() : "#");
}
function readInputs() {
state.q = $("q").value.trim();
state.site = $("site").value.trim();
state.range = $("range").value;
state.sort = $("sort").value;
state.page = 1;
}
async function run() {
hideSuggest();
if (!state.q) {
$("info").textContent = "";
$("results").innerHTML = "";
$("pager").style.display = "none";
$("hint").style.display = "";
return;
}
$("hint").style.display = "none";
$("info").className = "info";
$("info").textContent = state.view === "images"
? "Suche Bilder …" : "Suche …";
$("results").innerHTML = "";
if (state.view === "images") { await runImages(); return; }
const p = new URLSearchParams({ q: state.q, page: state.page });
if (state.site) p.set("site", state.site);
if (state.range) p.set("after", state.range);
if (state.sort) p.set("sort", state.sort);
let data;
try {
const res = await fetch("/api/search?" + p.toString());
data = await res.json();
if (!res.ok) {
$("info").className = "info error";
$("info").textContent = data.error ||
("Fehler " + res.status);
return;
}
} catch (e) {
$("info").className = "info error";
$("info").textContent = "Suchdienst nicht erreichbar.";
return;
}
const totalTxt = data.total != null
? "Etwa " + fmtInt(data.total) + " Treffer"
: "Viele Treffer";
const emptyHint = data.parsed_query ? "" :
" · keine durchsuchbaren Begriffe gefunden";
const capHint = data.ranking_capped ?
" · Ranking auf Top-Kandidaten eingegrenzt" : "";
const degradedHint = data.ranking_degraded ?
" · Trefferreihenfolge vereinfacht (Suchbegriffe sehr häufig)" : "";
$("info").textContent = totalTxt + " · Seite " + data.page +
" · " + (data.took_ms / 1000).toFixed(2) + " s" + emptyHint +
capHint + degradedHint;
if (!data.results.length) {
$("results").innerHTML =
'<div class="info">Keine Treffer. Andere Begriffe oder ' +
'weniger Filter probieren.</div>';
$("pager").style.display = "none";
return;
}
$("results").innerHTML = data.results.map(r => {
const snippet = esc(r.snippet)
.split("⟦").join("<mark>").split("⟧").join("</mark>");
return '<div class="result">' +
'<a class="title" href="' + esc(r.url) + '" target="_blank" ' +
'rel="noopener">' + esc(r.title) + "</a>" +
'<div class="url">' + esc(prettyUrl(r.url)) + "</div>" +
(snippet ? '<div class="snippet">' + snippet + "</div>" : "") +
'<div class="meta">' + esc(r.domain || prettyUrl(r.url)) +
" · " + fmtDate(r.crawled_at) +
" · Score " + r.score.toFixed(4) + "</div></div>";
}).join("");
$("pager").style.display = "";
$("pageinfo").textContent = "Seite " + data.page;
$("prev").disabled = data.page <= 1;
$("next").disabled = !data.has_more;
}
async function runImages() {
const p = new URLSearchParams({ q: state.q, page: state.page });
if (state.site) p.set("site", state.site);
if (state.range) p.set("after", state.range);
if (state.sort) p.set("sort", state.sort);
let data;
try {
const res = await fetch("/api/images?" + p.toString());
data = await res.json();
if (!res.ok) {
$("info").className = "info error";
$("info").textContent = data.error || ("Fehler " + res.status);
return;
}
} catch (e) {
$("info").className = "info error";
$("info").textContent = "Suchdienst nicht erreichbar.";
return;
}
const totalTxt = data.total != null
? "Etwa " + fmtInt(data.total) + " Bilder"
: "Bilder";
const capHint = data.ranking_capped
? " · Ranking auf Top-Kandidaten eingegrenzt" : "";
$("info").textContent = totalTxt + " · Seite " + data.page +
" · " + (data.took_ms / 1000).toFixed(2) + " s" + capHint;
if (!data.results.length) {
$("results").innerHTML =
'<div class="info">Keine Bilder zu diesen Begriffen gefunden. ' +
'Andere Begriffe oder weniger Filter probieren.</div>';
$("pager").style.display = "none";
return;
}
$("results").innerHTML = '<div class="img-grid">' +
data.results.map(r => {
const label = r.title || r.domain || prettyUrl(r.page_url);
return '<a class="img-card" href="' + esc(r.page_url) + '" ' +
'target="_blank" rel="noopener" title="' + esc(label) +
'"><img src="' + esc(r.image_url) + '" alt="' + esc(label) +
'" loading="lazy" decoding="async" ' +
'referrerpolicy="no-referrer"><span class="caption">' +
esc(label) + '</span></a>';
}).join("") + '</div>';
// Fehlende/blockierte Bilder dürfen die Ergebnisseite nicht
// beeinträchtigen: defekte Kacheln werden entfernt.
$("results").querySelectorAll(".img-card img").forEach(img => {
img.addEventListener("error", () => {
const card = img.closest(".img-card");
if (card) card.remove();
});
});
$("pager").style.display = "";
$("pageinfo").textContent = "Seite " + data.page;
$("prev").disabled = data.page <= 1;
$("next").disabled = !data.has_more;
}
/* ---------- Vorschläge (Autocomplete) ---------- */
function hideSuggest() { $("suggest").classList.add("hidden"); }
$("q").addEventListener("input", () => {
clearTimeout(suggestTimer);
suggestTimer = setTimeout(async () => {
const q = $("q").value.trim();
if (q.length < 2) { hideSuggest(); return; }
try {
const res = await fetch("/api/suggest?q=" + encodeURIComponent(q));
const data = await res.json();
const items = data.suggestions || [];
if (!items.length) { hideSuggest(); return; }
$("suggest").innerHTML = items.map(s =>
'<div class="suggest-item" data-title="' +
esc(s.title) + '"><span>' + esc(s.title) + "</span>" +
'<span class="dom">' + esc(s.domain) + "</span></div>"
).join("");
$("suggest").classList.remove("hidden");
} catch (e) { hideSuggest(); }
}, 250);
});
$("suggest").addEventListener("mousedown", e => {
const item = e.target.closest(".suggest-item");
if (item) {
$("q").value = item.dataset.title;
hideSuggest();
readInputs(); toHash(); run();
}
});
$("q").addEventListener("blur", () => setTimeout(hideSuggest, 180));
/* ---------- Ereignisse ---------- */
$("f").addEventListener("submit", e => {
e.preventDefault();
readInputs(); toHash(); run();
});
function setView(view) {
if (state.view === view) return;
state.view = view;
state.page = 1;
updateTabs();
toHash();
if (state.q) run();
}
$("tab-web").addEventListener("click", () => setView("web"));
$("tab-images").addEventListener("click", () => setView("images"));
$("prev").addEventListener("click", () => {
if (state.page > 1) { state.page--; toHash(); run(); }
});
$("next").addEventListener("click", () => { state.page++; toHash(); run(); });
document.addEventListener("keydown", e => {
if (e.key === "/" && document.activeElement !== $("q") &&
document.activeElement !== $("site")) {
e.preventDefault(); $("q").focus();
}
});
/* ---------- Start ---------- */
fromHash();
if (state.q) run();
(async () => {
try {
const res = await fetch("/api/stats");
if (!res.ok) return;
const s = await res.json();
if (s.pages) {
const b = $("stats-badge");
b.textContent = "≈ " + fmtInt(s.pages) + " Seiten indexiert";
b.style.display = "";
}
} catch (e) { /* Statistik optional */ }
})();
</script>
</body>
</html>
"""
@app.route("/")
def index():
return render_template_string(HTML)
# ============================================================
# START
# ============================================================
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
prog="search/api.py",
description="JonasSearch Suchmaschine (HTTP-API + Weboberfläche)",
)
parser.add_argument("--host", default=None,
help="Bind-Adresse (Default: JONASSEARCH_API_HOST "
"oder 0.0.0.0)")
parser.add_argument("--port", type=int, default=None,
help="Port (Default: JONASSEARCH_API_PORT oder 8083)")
args = parser.parse_args()
host = args.host or os.getenv("JONASSEARCH_API_HOST",
os.getenv("HOST", "0.0.0.0"))
port = args.port or int(os.getenv("JONASSEARCH_API_PORT",
str(DEFAULT_PORT)))
print("=" * 70)
print(" JonasSearch Suchmaschine")
print("=" * 70)
print(f"Statserver : Port 8081 (jonassearch-stats.service)")
print(f"Priorität : Port 8082 (jonassearch-priority.service)")
print(f"Suchmaschine: http://{host}:{port}")
print(f"DB : {engine.DB_HOST}:{engine.DB_PORT}/{engine.DB_NAME}")
print(f"TS-Config : {engine.TS_CONFIG}")
print("=" * 70)
app.run(
host=host,
port=port,
debug=False,
threaded=True,
load_dotenv=False, # .env wurde oben schon geladen
)