318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""
|
||
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'<meta property="og:title" content="{_escape(title)}" />\n'
|
||
f'<meta property="og:description" content="{_escape(desc)}" />\n'
|
||
f'<meta property="og:url" content="{_escape(m["url"] or target_url)}" />\n'
|
||
f'<meta property="og:type" content="{_escape(m["type"])}" />'
|
||
)
|
||
if image:
|
||
head_tags += f'\n<meta property="og:image" content="{_escape(image)}" />'
|
||
if site_name:
|
||
head_tags += f'\n<meta property="og:site_name" content="{_escape(site_name)}" />'
|
||
if favicon:
|
||
head_tags += f'\n<link rel="icon" href="{_escape(favicon)}" />'
|
||
|
||
# Einfache Zwischenseite mit Auto-Weiterleitung nach 0 Sekunden,
|
||
# damit auch Menschen, die die Seite direkt ansehen, weiterkommen.
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>{_escape(title)}</title>
|
||
{head_tags}
|
||
<meta name="referrer" content="no-referrer" />
|
||
<meta http-equiv="refresh" content="0;url={_escape(target_url)}" />
|
||
<style>
|
||
body {{ font-family: -apple-system, Segoe UI, Roboto, sans-serif;
|
||
background:#f6f7f9; color:#1c1e21; display:flex; align-items:center;
|
||
justify-content:center; min-height:100vh; margin:0; }}
|
||
.card {{ background:#fff; border-radius:12px; box-shadow:0 2px 10px rgba(0,0,0,.1);
|
||
max-width:480px; padding:32px; text-align:center; }}
|
||
.card h1 {{ font-size:22px; margin:0 0 8px; }}
|
||
.card p {{ color:#555; margin:0 0 20px; }}
|
||
a.button {{ display:inline-block; background:#1877f2; color:#fff; text-decoration:none;
|
||
padding:10px 22px; border-radius:8px; font-weight:600; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<h1>{_escape(title)}</h1>
|
||
<p>{_escape(desc)}</p>
|
||
<a class="button" href="{_escape(target_url)}">Weiter zum Ziel</a>
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
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
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _handle_link(target_url: str):
|
||
"""Leitet weiter oder liefert die Preview-Seite für Bots."""
|
||
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.route("/")
|
||
def index():
|
||
routes = load_config()
|
||
|
||
# Ist die Hauptdomain (/) konfiguriert, wird sie weitergeleitet
|
||
# statt die Linkliste anzuzeigen.
|
||
if "/" in routes:
|
||
return _handle_link(routes["/"])
|
||
|
||
rows = "".join(
|
||
f'<li><a href="{_escape(p)}">{_escape(p)}</a> → '
|
||
f'<small>{_escape(r)}</small></li>'
|
||
for p, r in sorted(routes.items())
|
||
)
|
||
return (
|
||
"<!DOCTYPE html><html><head><meta charset='utf-8'><title>Links</title></head>"
|
||
"<body><h1>Verfügbare Links</h1><ul>" + (rows or "<li>Keine Routen konfiguriert.</li>")
|
||
+ "</ul></body></html>",
|
||
200,
|
||
{"Content-Type": "text/html; charset=utf-8"},
|
||
)
|
||
|
||
|
||
@app.route("/<path:path>")
|
||
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)
|
||
|
||
return _handle_link(target_url)
|
||
|
||
|
||
@app.errorhandler(404)
|
||
def not_found(_):
|
||
return (
|
||
"<!DOCTYPE html><html><head><meta charset='utf-8'><title>404</title></head>"
|
||
"<body><h1>404 – Nicht gefunden</h1><p>Dieser Link existiert nicht.</p></body></html>",
|
||
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)
|