742 lines
26 KiB
Python
742 lines
26 KiB
Python
#!/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 – "exakte Phrase", 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">‹ Zurück</button>
|
||
<span id="pageinfo" class="pageinfo"></span>
|
||
<button id="next">Weiter ›</button>
|
||
</div>
|
||
|
||
<div class="hint" id="hint">
|
||
Beispiele: <code>postgreSQL volltextsuche</code> ·
|
||
<code>"exacte phrase"</code> ·
|
||
<code>crawler -python</code> ·
|
||
<code>postgres OR sqlite</code>
|
||
</div>
|
||
|
||
<footer>JonasSearch Suchmaschine · PostgreSQL-Volltextsuche
|
||
über gecrawlte Webseiten · 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("&", "&").replaceAll("<", "<")
|
||
.replaceAll(">", ">").replaceAll('"', """);
|
||
}
|
||
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
|
||
)
|