new file: README.md

new file:   app.py
	new file:   config.json
	new file:   requirements.txt
This commit is contained in:
SimolZimol
2026-08-03 22:39:09 +02:00
parent 87f58e2ff3
commit 13b72c2e8b
4 changed files with 395 additions and 0 deletions

79
README.md Normal file
View File

@@ -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: <http://localhost:5000/cool>
## 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 <http://localhost:8000> (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.

306
app.py Normal file
View 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("&", "&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)

6
config.json Normal file
View File

@@ -0,0 +1,6 @@
{
"routes": {
"/community-mp": "https://lubiethistoria.com/community-mp",
"/": "https://lubiethistoria.com/"
}
}

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
flask>=3.0,<4
requests>=2.31,<3
beautifulsoup4>=4.12,<5
gunicorn>=21.2,<23