From 13b72c2e8b84f2dadecf73e2796986308f0477b2 Mon Sep 17 00:00:00 2001 From: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:39:09 +0200 Subject: [PATCH] new file: README.md new file: app.py new file: config.json new file: requirements.txt --- README.md | 79 ++++++++++++ app.py | 306 +++++++++++++++++++++++++++++++++++++++++++++++ config.json | 6 + requirements.txt | 4 + 4 files changed, 395 insertions(+) create mode 100644 README.md create mode 100644 app.py create mode 100644 config.json create mode 100644 requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf101c2 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Links – Flask Redirect & Link-Preview-Server + +Ein kleiner Flask-Webserver, der festgelegte Pfade auf externe URLs umleitet. +Damit Social-Media-Plattformen (WhatsApp, Facebook, Twitter/X, LinkedIn …) +eine korrekte **Link-Preview** der Zielseite anzeigen, bekommen Crawler eine +HTML-Seite mit den **kopierten Open-Graph-Metadaten** der Original-Webseite. + +## Funktionsweise + +- **Normale Browser** → `302`-Weiterleitung direkt zur Ziel-URL. +- **Social-Media-Crawler / Preview-Bots** (anhand des User-Agents erkannt) → + eine HTML-Seite mit den `og:`-Meta-Tags (Titel, Beschreibung, Bild) der + Ziel-URL wird ausgeliefert. So funktionieren Previews mit *deinem* Pfad. + +Die Metadaten werden live von der Zielseite geholt und zwischengespeichert +(TTL standardmäßig 1 Stunde), um die Zielseite nicht zu überlasten. + +## Konfiguration + +Alle Routen stehen in **`config.json`**: + +```json +{ + "routes": { + "/cool": "https://youtube.net", + "/github": "https://github.com" + } +} +``` + +Einfach Paare aus **Server-Pfad** und **Ziel-URL** eintragen. Die Datei wird +bei jeder Anfrage auf Änderungen geprüft – **kein Neustart nötig**. + +| Eintrag | Bedeutung | +|------------------|--------------------------------------------| +| `/cool` | `https://deine-domain.de/cool` | +| Wert | Ziel-URL (z. B. `https://youtube.net`) | + +## Umgebungsvariablen + +| Variable | Standard | Bedeutung | +|--------------------------|----------|---------------------------------------------| +| `LINKS_CONFIG` | `config.json` | Pfad zur Config-Datei | +| `LINKS_CACHE_TTL` | `3600` | Cache-Zeit für Metadaten (Sekunden) | +| `LINKS_FETCH_TIMEOUT` | `10` | Timeout beim Abrufen der Zielseite (Sek.) | +| `PORT` | `5000` | Port im lokalen Debug-Modus | + +## Lokal starten + +```bash +python -m venv .venv +.venv\Scripts\activate # Windows +pip install -r requirements.txt +python app.py +``` + +Danach im Browser aufrufen: + +## Mit Docker + +```bash +docker build -t links . +docker run -p 8000:8000 \ + -v "$(pwd)/config.json:/app/config.json" \ + links +``` + +Der Server läuft dann unter (Gunicorn, Port 8000). + +## Test der Preview + +Um zu prüfen, ob ein Social-Crawler die Metadaten korrekt ausliefert: + +```bash +curl -A "facebookexternalhit/1.1" http://localhost:5000/cool +``` + +Du solltest eine HTML-Seite mit `og:title`, `og:description` und `og:image` +der Zielseite erhalten. Ohne diesen Header bekommst du die `302`-Weiterleitung. diff --git a/app.py b/app.py new file mode 100644 index 0000000..ba5294b --- /dev/null +++ b/app.py @@ -0,0 +1,306 @@ +""" +Links – Flask Redirect & Link-Preview-Server +============================================= + +Leitet festgelegte Routen (siehe config.json) auf externe URLs um. + +Damit Social-Media-Plattformen (Facebook, WhatsApp, Twitter/X, LinkedIn …) +eine korrekte Link-Preview (Open-Graph-Metadaten) der *Ziel*-Seite anzeigen, +wird bei Crawler-Anfragen nicht direkt weitergeleitet, sondern eine HTML-Seite +mit den kopierten Metadaten der Zielseite ausgeliefert. Normale Browser +bekommen eine einfache 302-Weiterleitung. +""" + +import json +import logging +import os +import re +import time +from urllib.parse import urlparse + +import requests +from bs4 import BeautifulSoup +from flask import Flask, Response, abort, redirect, request + +# --------------------------------------------------------------------------- +# Konfiguration +# --------------------------------------------------------------------------- + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +CONFIG_PATH = os.environ.get("LINKS_CONFIG", os.path.join(BASE_DIR, "config.json")) +CACHE_TTL = int(os.environ.get("LINKS_CACHE_TTL", "3600")) # Sekunden (1 Std.) +FETCH_TIMEOUT = float(os.environ.get("LINKS_FETCH_TIMEOUT", "10")) + +logging.basicConfig(level=logging.INFO) +log = logging.getLogger("links") + +app = Flask(__name__) + +# --------------------------------------------------------------------------- +# Config laden +# --------------------------------------------------------------------------- + +# Wenn eine Änderung an config.json vorgenommen wird, wird sie automatisch +# neu geladen (Last-Modified-Check). +_config_mtime = 0.0 +_routes = {} + + +def load_config(force: bool = False) -> dict: + """Lädt die Routen-Konfiguration. Liefert dict: {path: {url, ...}}.""" + global _config_mtime, _routes + try: + mtime = os.path.getmtime(CONFIG_PATH) + except FileNotFoundError: + log.error("config.json nicht gefunden: %s", CONFIG_PATH) + return {} + + if not force and mtime == _config_mtime and _routes: + return _routes + + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as fh: + data = json.load(fh) + except (json.JSONDecodeError, OSError) as exc: + log.error("config.json konnte nicht gelesen werden: %s", exc) + return _routes + + routes = data.get("routes", {}) + normalized = {} + for path, target in routes.items(): + path = path if path.startswith("/") else "/" + path + normalized[path] = target + _routes = normalized + _config_mtime = mtime + log.info("Konfiguration geladen: %d Route(n)", len(_routes)) + return _routes + + +# --------------------------------------------------------------------------- +# Open-Graph-Metadaten sammeln + cachen +# --------------------------------------------------------------------------- + +_CACHE = {} # url -> {"expires": timestamp, "meta": {...}} + + +def _looks_like_bot() -> bool: + """Erkennt Social-Media-/Preview-Crawler anhand des User-Agents.""" + ua = request.headers.get("User-Agent", "").lower() + bots = ( + "facebookexternalhit", "facebot", "twitterbot", "xbot", "linkedinbot", + "whatsapp", "slackbot", "discordbot", "telegrambot", "pinterest", + "tumblr", "redditbot", "vkShare", "vkbot", "embedly", "quora link", + "outbrain", "bingbot", "googlebot", "duckduckbot", "baiduspider", + "yandex", "skypeuripreview", "snapchat", "line-pc", "tinderbot", + "curl", "wget", "python-requests", "preview", "bot", + ) + return any(bot in ua for bot in bots) + + +def _extract_meta(soup: BeautifulSoup) -> dict: + """Extrahiert die wichtigsten Open-Graph-/Meta-Tags aus der Zielseite.""" + meta = { + "title": _meta_content(soup, "og:title") or _tag_text(soup, "title") or "", + "description": _meta_content(soup, "og:description") + or _meta_content(soup, "description") or "", + "image": _meta_content(soup, "og:image") or _meta_content(soup, "og:image:url") or "", + "url": _meta_content(soup, "og:url") or "", + "site_name": _meta_content(soup, "og:site_name") or "", + "type": _meta_content(soup, "og:type") or "website", + "favicon": _favicon(soup), + } + # Vorangestelltes Präfix (z. B. "Seite - Titel") entfernen + if " - " in meta["title"]: + meta["title"] = meta["title"].split(" - ")[0].strip() or meta["title"] + return {k: (v or "").strip() for k, v in meta.items()} + + +def _meta_content(soup: BeautifulSoup, prop: str) -> str: + tag = soup.find("meta", attrs={"property": prop}) or soup.find( + "meta", attrs={"name": prop} + ) + return tag.get("content", "").strip() if tag else "" + + +def _tag_text(soup: BeautifulSoup, name: str) -> str: + tag = soup.find(name) + return tag.get_text(strip=True) if tag else "" + + +def _favicon(soup: BeautifulSoup) -> str: + icon = soup.find("link", rel=lambda r: r and "icon" in r.lower()) + return icon.get("href", "") if icon else "" + + +def _resolve_absolute(base_url: str, value: str) -> str: + if not value: + return "" + if value.startswith(("http://", "https://", "//")): + if value.startswith("//"): + return "https:" + value + return value + return requests.compat.urljoin(base_url, value) + + +def fetch_meta(target_url: str) -> dict: + """Holt die Metadaten der Ziel-URL, mit einfachem TTL-Cache.""" + now = time.time() + cached = _CACHE.get(target_url) + if cached and cached["expires"] > now: + return cached["meta"] + + meta = {"title": "", "description": "", "image": "", "url": target_url, + "site_name": "", "type": "website", "favicon": ""} + try: + resp = requests.get(target_url, timeout=FETCH_TIMEOUT, allow_redirects=True, + headers={"User-Agent": "Mozilla/5.0 (compatible; LinkPreview/1.0)"}) + resp.raise_for_status() + if "text/html" not in resp.headers.get("Content-Type", ""): + raise ValueError("Keine HTML-Seite") + soup = BeautifulSoup(resp.text, "html.parser") + meta = _extract_meta(soup) + base = resp.url or target_url + meta["image"] = _resolve_absolute(base, meta["image"]) + meta["favicon"] = _resolve_absolute(base, meta["favicon"]) + if not meta["url"]: + meta["url"] = base + except Exception as exc: # noqa: BLE001 – Zielseite darf den Server nicht brechen + log.warning("Metadaten für %s konnten nicht geladen werden: %s", target_url, exc) + + _CACHE[target_url] = {"expires": now + CACHE_TTL, "meta": meta} + return meta + + +def _render_preview_page(target_url: str) -> str: + """Baut eine HTML-Seite mit den OG-Metadaten der Zielseite.""" + m = fetch_meta(target_url) + title = m["title"] or "Link" + desc = m["description"] or "Dieser Link leitet weiter." + image = m["image"] or "" + site_name = m["site_name"] or "" + favicon = m["favicon"] or "" + + # Noch einmal auf den Ziel-Pfad weiterleiten, falls der Crawler doch + # weitergehen möchte, und die Metadaten für Bots einsetzen. + head_tags = ( + f'\n' + f'\n' + f'\n' + f'' + ) + if image: + head_tags += f'\n' + if site_name: + head_tags += f'\n' + if favicon: + head_tags += f'\n' + + # Einfache Zwischenseite mit Auto-Weiterleitung nach 0 Sekunden, + # damit auch Menschen, die die Seite direkt ansehen, weiterkommen. + html = f""" + + + +{_escape(title)} +{head_tags} + + + + + +
+

{_escape(title)}

+

{_escape(desc)}

+ Weiter zum Ziel +
+ +""" + return html + + +def _escape(value: str) -> str: + """HTML-Escaping für sichere Einbettung in das Markup.""" + return ( + str(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) + + +# --------------------------------------------------------------------------- +# Routen +# --------------------------------------------------------------------------- + +@app.route("/") +def index(): + routes = load_config() + rows = "".join( + f'
  • {_escape(p)} → ' + f'{_escape(r)}
  • ' + for p, r in sorted(routes.items()) + ) + return ( + "Links" + "

    Verfügbare Links

    ", + 200, + {"Content-Type": "text/html; charset=utf-8"}, + ) + + +@app.route("/") +def link_redirect(path: str): + routes = load_config() + target_url = routes.get("/" + path) + + # Alternativ: ohne führenden Slash in der Config? + if target_url is None: + target_url = routes.get(path) + + if target_url is None: + abort(404) + + # Social-Media-Bots / Preview-Crawler bekommen die Metadaten-Seite + if _looks_like_bot(): + return Response(_render_preview_page(target_url), mimetype="text/html") + + # Alle anderen werden direkt weitergeleitet + return redirect(target_url, code=302) + + +@app.errorhandler(404) +def not_found(_): + return ( + "404" + "

    404 – Nicht gefunden

    Dieser Link existiert nicht.

    ", + 404, + {"Content-Type": "text/html; charset=utf-8"}, + ) + + +@app.before_request +def refresh_config(): + """Liest config.json bei jeder Anfrage neu, falls sie sich geändert hat.""" + load_config() + + +# Lokale Konfiguration beim Start laden +load_config() + +if __name__ == "__main__": + # Debug-Server (nur für lokale Entwicklung) + port = int(os.environ.get("PORT", "5000")) + app.run(host="0.0.0.0", port=port, debug=True) diff --git a/config.json b/config.json new file mode 100644 index 0000000..ae3490c --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "routes": { + "/community-mp": "https://lubiethistoria.com/community-mp", + "/": "https://lubiethistoria.com/" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5c7544a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask>=3.0,<4 +requests>=2.31,<3 +beautifulsoup4>=4.12,<5 +gunicorn>=21.2,<23