""" 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)