Files
Redirect/app.py
SimolZimol 13b72c2e8b new file: README.md
new file:   app.py
	new file:   config.json
	new file:   requirements.txt
2026-08-03 22:39:09 +02:00

307 lines
11 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.
"""
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#39;")
)
# ---------------------------------------------------------------------------
# Routen
# ---------------------------------------------------------------------------
@app.route("/")
def index():
routes = load_config()
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)
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 (
"<!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)