new file: README.md
new file: app.py new file: config.json new file: requirements.txt
This commit is contained in:
306
app.py
Normal file
306
app.py
Normal file
@@ -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'<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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@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)
|
||||
Reference in New Issue
Block a user