feat: tägliche KI-generierte Fake-Startup-Seite mit Archiv und Auflösungsseite

MOBEA ist jeden Tag ein anderes Startup: Ein Generator-Container erzeugt
1x täglich via OpenRouter Texte und via Pixazo SDXL ein Hero-Bild,
rendert statisches HTML nach public/ (nginx) und archiviert alle
Ausgaben unter /archiv. Alle CTAs führen auf die Satire-Auflösung
unter /echt-jetzt/.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-06-12 17:17:52 +02:00
commit 8874c63262
13 changed files with 2189 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# OpenRouter (Text-Generierung, 1 Call/Tag)
OPENROUTER_API_KEY=
# frei wählbares Modell, z.B. ein günstiges
OPENROUTER_MODEL=openai/gpt-4o-mini
# Pixazo SDXL (Hero-Bild, kostenlos) - Ocp-Apim-Subscription-Key
PIXAZO_API_KEY=
+6
View File
@@ -0,0 +1,6 @@
.env
__pycache__/
data/
public/index.html
public/archiv/
public/img/
+29
View File
@@ -0,0 +1,29 @@
services:
mobeade:
image: "nginx"
volumes:
- "./public:/usr/share/nginx/html:ro"
- "./nginx.conf:/etc/nginx/nginx.conf:ro"
container_name: "mobeade"
restart: always
labels:
- "traefik.enable=true"
- "traefik.http.routers.mobeade.rule=Host(`mobea.de`)||Host(`www.mobea.de`)"
- "traefik.http.routers.mobeade.entrypoints=websecure"
- "traefik.http.routers.mobeade.tls.certresolver=myresolver"
networks:
- traefik
mobea-generator:
build: ./generator
container_name: mobea-generator
restart: always
env_file: .env
volumes:
- "./public:/site"
- "./data:/data"
networks:
traefik:
external: true
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-alpine
WORKDIR /app
ENV TZ=UTC \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["sh", "/app/entrypoint.sh"]
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Kein `set -e`: ein Fehlschlag von generate.py darf die Schleife nicht beenden,
# der nächste Versuch erfolgt nach einer Stunde.
while true; do
python /app/generate.py
exit_code=$?
if [ "$exit_code" -ne 0 ]; then
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') generate.py exited with code $exit_code"
fi
sleep 3600
done
+363
View File
@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""Daily mobea.de startup page generator."""
from __future__ import annotations
import json
import os
import random
import re
import sys
from datetime import date, datetime, timezone
from pathlib import Path
import requests
from jinja2 import Environment, FileSystemLoader
import topics
GERMAN_MONTHS = [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
]
THEMES = ["corporate", "neon", "mystic", "brutalist", "pastel"]
REQUIRED_LLM_KEYS = [
"backronym_words",
"tagline",
"subtitle",
"pitch",
"features",
"pricing",
"testimonials",
"faq",
"cta",
"stats",
"image_prompt",
"image_negative_prompt",
]
LLM_SCHEMA = """{
"backronym_words": ["Modulare", "Orbitale", "Bewusstseins-", "Energie-", "Allianz"],
"tagline": "kurzer knackiger Claim",
"subtitle": "1-2 Sätze Hero-Untertitel",
"pitch": "Absatz 'Über uns', 3-5 Sätze, völlig ernst im Ton",
"features": [{"icon": "🔮", "title": "...", "text": "1-2 Sätze"}],
"pricing": [{"name": "Starter", "price": "9,99 €", "period": "/Monat", "features": ["...","...","..."], "cta": "Jetzt starten"}],
"testimonials": [{"quote": "...", "author": "Vorname N.", "role": "Beruf, Stadt"}],
"faq": [{"q": "...", "a": "..."}],
"cta": "Haupt-Button-Text",
"stats": [{"value": "98,3%", "label": "..."}],
"image_prompt": "english SDXL prompt for the hero image, no text in image",
"image_negative_prompt": "english negative prompt"
}"""
SCRIPT_DIR = Path(__file__).resolve().parent
def log(message: str) -> None:
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
print(f"{ts} {message}", flush=True)
def date_human(day: date) -> str:
return f"{day.day}. {GERMAN_MONTHS[day.month - 1]} {day.year}"
def load_archive(data_dir: Path) -> list[dict]:
path = data_dir / "archive.json"
if not path.exists():
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, list):
return data
log(f"warning: {path} is not a list, starting fresh")
except (json.JSONDecodeError, OSError) as exc:
log(f"warning: could not read {path}: {exc}, starting fresh")
return []
def save_json_atomic(path: Path, data: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, path)
def write_text_atomic(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(content, encoding="utf-8")
os.replace(tmp, path)
def strip_json_fences(text: str) -> str:
stripped = text.strip()
if stripped.startswith("```"):
stripped = re.sub(r"^```(?:json)?\s*", "", stripped)
stripped = re.sub(r"\s*```$", "", stripped)
return stripped.strip()
def validate_llm_json(data: dict) -> None:
missing = [key for key in REQUIRED_LLM_KEYS if key not in data]
if missing:
raise ValueError(f"missing required keys: {', '.join(missing)}")
if len(data.get("backronym_words", [])) != 5:
log("warning: backronym_words should contain exactly 5 words")
features = data.get("features", [])
if not (3 <= len(features) <= 4):
log(f"warning: expected 3-4 features, got {len(features)}")
pricing = data.get("pricing", [])
if len(pricing) != 3:
log(f"warning: expected exactly 3 pricing tiers, got {len(pricing)}")
elif pricing[-1].get("name") != "Enterprise":
log("warning: last pricing tier should be named 'Enterprise'")
elif pricing[-1].get("price") != "Auf Anfrage":
log("warning: Enterprise tier price should be 'Auf Anfrage'")
elif pricing[-1].get("cta") != "Kontaktieren Sie uns":
log("warning: Enterprise tier cta should be 'Kontaktieren Sie uns'")
testimonials = data.get("testimonials", [])
if not (2 <= len(testimonials) <= 3):
log(f"warning: expected 2-3 testimonials, got {len(testimonials)}")
faq = data.get("faq", [])
if not (3 <= len(faq) <= 4):
log(f"warning: expected 3-4 faq entries, got {len(faq)}")
stats = data.get("stats", [])
if len(stats) != 3:
log(f"warning: expected exactly 3 stats, got {len(stats)}")
def build_system_prompt() -> str:
return (
"Du bist Texter für eine Satire-Website namens mobea.de "
"'das Startup, das jeden Tag ein anderes Startup ist'. "
"Schreibe ausschließlich auf Deutsch (außer image_prompt und image_negative_prompt). "
"Der Humor entsteht durch absurde Inhalte in völlig ernstem Startup-Marketing-Ton. "
"Antworte ausschließlich mit validem JSON nach diesem Schema:\n"
f"{LLM_SCHEMA}\n\n"
"Pflichtregeln: backronym_words = genau 5 Wörter, deren Anfangsbuchstaben "
"zusammen MOBEA ergeben; 3-4 features; genau 3 pricing tiers, der letzte "
"name 'Enterprise', price 'Auf Anfrage', cta 'Kontaktieren Sie uns'; "
"2-3 testimonials; 3-4 faq; genau 3 stats."
)
def build_user_prompt(selected_topics: list[dict]) -> str:
lines = ["Themen des Tages:"]
for topic in selected_topics:
lines.append(f"- {topic['name']}: {topic['hint']}")
lines.append(
"Erfinde ein völlig neues, absurd-satirisches Startup im todernsten "
"Marketing-Ton. Alle Texte müssen wie echtes Startup-Deutsch klingen."
)
return "\n".join(lines)
def call_openrouter(api_key: str, model: str, selected_topics: list[dict]) -> dict:
payload = {
"model": model,
"temperature": 0.9,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": build_system_prompt()},
{"role": "user", "content": build_user_prompt(selected_topics)},
],
}
response = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
timeout=120,
)
response.raise_for_status()
body = response.json()
content = body["choices"][0]["message"]["content"]
data = json.loads(strip_json_fences(content))
validate_llm_json(data)
return data
def generate_hero_image(
api_key: str,
site_dir: Path,
date_iso: str,
prompt: str,
negative_prompt: str,
) -> str | None:
try:
response = requests.post(
"https://gateway.pixazo.ai/getImage/v1/getSDXLImage",
headers={
"Content-Type": "application/json",
"Cache-Control": "no-cache",
"Ocp-Apim-Subscription-Key": api_key,
},
json={
"prompt": prompt,
"negative_prompt": negative_prompt,
"height": 1024,
"width": 1024,
"num_steps": 20,
"guidance_scale": 5,
},
timeout=180,
)
response.raise_for_status()
body = response.json()
image_url = body.get("imageUrl") or body.get("output")
if not image_url:
log("warning: Pixazo response missing imageUrl/output")
return None
img_response = requests.get(image_url, timeout=120)
img_response.raise_for_status()
img_dir = site_dir / "img"
img_dir.mkdir(parents=True, exist_ok=True)
img_path = img_dir / f"{date_iso}.jpg"
img_path.write_bytes(img_response.content)
return f"/img/{date_iso}.jpg"
except (requests.RequestException, OSError, KeyError, TypeError, ValueError) as exc:
log(f"warning: hero image generation failed: {exc}")
return None
def render_template(name: str, context: dict) -> str:
env = Environment(
loader=FileSystemLoader(SCRIPT_DIR / "templates"),
autoescape=True,
)
return env.get_template(name).render(**context)
def build_archiv_entries(archive: list[dict]) -> list[dict]:
entries = []
for item in sorted(archive, key=lambda e: e["date"], reverse=True):
entries.append(
{
"date": item["date"],
"date_human": item["date_human"],
"backronym": item["backronym"],
"tagline": item["tagline"],
"url": f"/archiv/{item['date']}.html",
"image": f"/img/{item['date']}.jpg" if item.get("has_image") else None,
"topics": item.get("topics", []),
}
)
return entries
def main() -> int:
api_key = os.environ.get("OPENROUTER_API_KEY")
model = os.environ.get("OPENROUTER_MODEL")
pixazo_key = os.environ.get("PIXAZO_API_KEY")
site_dir = Path(os.environ.get("SITE_DIR", "/site"))
data_dir = Path(os.environ.get("DATA_DIR", "/data"))
generate_hour = int(os.environ.get("GENERATE_HOUR_UTC", "5"))
if not api_key:
log("error: OPENROUTER_API_KEY is required")
return 1
if not model:
log("error: OPENROUTER_MODEL is required")
return 1
now = datetime.now(timezone.utc)
today = now.date()
date_iso = today.isoformat()
index_path = site_dir / "index.html"
archive = load_archive(data_dir)
if any(entry.get("date") == date_iso for entry in archive) and index_path.exists():
log("up to date")
return 0
if index_path.exists() and now.hour < generate_hour:
log(f"waiting until {generate_hour:02d}:00 UTC (current hour: {now.hour})")
return 0
selected_topics = topics.pick_topics(today)
theme = random.Random(f"theme-{date_iso}").choice(THEMES)
log(f"generating edition for {date_iso} theme={theme} topics={[t['name'] for t in selected_topics]}")
try:
llm_json = call_openrouter(api_key, model, selected_topics)
except (requests.RequestException, json.JSONDecodeError, KeyError, ValueError) as exc:
log(f"error: OpenRouter call failed: {exc}")
return 1
hero_image = None
if pixazo_key:
hero_image = generate_hero_image(
pixazo_key,
site_dir,
date_iso,
llm_json["image_prompt"],
llm_json["image_negative_prompt"],
)
else:
log("PIXAZO_API_KEY not set, skipping hero image")
human_date = date_human(today)
startup_context = {
"date": date_iso,
"date_human": human_date,
"theme": theme,
"hero_image": hero_image,
"s": llm_json,
}
startup_html = render_template("startup.html.j2", startup_context)
archiv_day_path = site_dir / "archiv" / f"{date_iso}.html"
write_text_atomic(index_path, startup_html)
write_text_atomic(archiv_day_path, startup_html)
log(f"wrote {index_path} and {archiv_day_path}")
archive_entry = {
"date": date_iso,
"date_human": human_date,
"backronym": " ".join(llm_json["backronym_words"]),
"tagline": llm_json["tagline"],
"theme": theme,
"topics": [topic["name"] for topic in selected_topics],
"has_image": hero_image is not None,
}
archive = [entry for entry in archive if entry.get("date") != date_iso]
archive.append(archive_entry)
save_json_atomic(data_dir / "archive.json", archive)
log(f"updated {data_dir / 'archive.json'}")
archiv_html = render_template(
"archiv.html.j2",
{"entries": build_archiv_entries(archive)},
)
write_text_atomic(site_dir / "archiv" / "index.html", archiv_html)
log(f"wrote {site_dir / 'archiv' / 'index.html'}")
return 0
if __name__ == "__main__":
sys.exit(main())
+2
View File
@@ -0,0 +1,2 @@
requests==2.32.3
jinja2==3.1.6
+270
View File
@@ -0,0 +1,270 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MOBEA Archiv Unsere bisherigen Pivots</title>
<meta name="description" content="Jeden Tag ein neues Startup. Hier ruhen die bisherigen.">
<meta name="theme-color" content="#111118">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #111118;
color: #e4e4ec;
line-height: 1.6;
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }
.container {
width: 100%;
max-width: 1080px;
margin: 0 auto;
padding: 0 1.25rem;
}
/* Header */
.header {
border-bottom: 1px solid #2a2a38;
padding: 1.5rem 0;
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.logo {
font-weight: 800;
font-size: 1.25rem;
letter-spacing: 0.12em;
color: #ffffff;
}
.header-nav {
display: flex;
gap: 1.5rem;
font-size: 0.85rem;
}
.header-nav a {
color: #8888a0;
transition: color 0.2s;
}
.header-nav a:hover { color: #c8c8d8; }
/* Main */
.main { padding: 3rem 0 4rem; }
.intro {
margin-bottom: 3rem;
}
.intro h1 {
font-size: clamp(1.5rem, 4vw, 2.25rem);
font-weight: 700;
color: #ffffff;
margin-bottom: 0.75rem;
}
.intro p {
font-size: 1.05rem;
color: #8888a0;
max-width: 36rem;
}
/* Empty state */
.empty {
text-align: center;
padding: 4rem 2rem;
border: 1px dashed #2a2a38;
border-radius: 12px;
color: #8888a0;
font-size: 1.1rem;
}
/* Grid */
.archive-grid {
display: grid;
gap: 1.5rem;
}
@media (min-width: 600px) {
.archive-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 900px) {
.archive-grid { grid-template-columns: repeat(3, 1fr); }
}
.entry-card {
display: flex;
flex-direction: column;
background: #1a1a24;
border: 1px solid #2a2a38;
border-radius: 12px;
overflow: hidden;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
}
.entry-card:hover {
border-color: #4a4a60;
transform: translateY(-3px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
.entry-thumb {
aspect-ratio: 16 / 9;
overflow: hidden;
background: linear-gradient(135deg, #2a2040 0%, #1a2838 50%, #283020 100%);
}
.entry-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.entry-body {
padding: 1.25rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.entry-date {
font-size: 0.75rem;
color: #686880;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.entry-backronym {
font-size: 0.95rem;
font-weight: 700;
color: #ffffff;
line-height: 1.35;
}
.entry-tagline {
font-size: 0.85rem;
color: #a0a0b8;
flex: 1;
}
.entry-topics {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
}
.topic-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: #2a2a38;
color: #a0a0b8;
border: 1px solid #3a3a50;
}
/* Footer */
.footer {
border-top: 1px solid #2a2a38;
padding: 2rem 0;
margin-top: auto;
}
.footer-inner {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 1rem;
font-size: 0.8rem;
color: #686880;
}
.footer-links {
display: flex;
gap: 1.5rem;
}
.footer-links a {
color: #8888a0;
transition: color 0.2s;
}
.footer-links a:hover { color: #c8c8d8; }
</style>
</head>
<body>
<header class="header">
<div class="container header-inner">
<span class="logo">MOBEA</span>
<nav class="header-nav">
<a href="/">Heute</a>
<a href="/echt-jetzt/">Kontakt</a>
</nav>
</div>
</header>
<main class="main">
<div class="container">
<div class="intro">
<h1>Unsere bisherigen Pivots</h1>
<p>Jeden Tag ein neues Startup. Hier ruhen die bisherigen.</p>
</div>
{% if entries %}
<div class="archive-grid">
{% for entry in entries %}
<a href="{{ entry.url }}" class="entry-card">
<div class="entry-thumb">
{% if entry.image %}
<img src="{{ entry.image }}" alt="{{ entry.backronym }}" width="360" height="203" loading="lazy">
{% endif %}
</div>
<div class="entry-body">
<time class="entry-date" datetime="{{ entry.date }}">{{ entry.date_human }}</time>
<h2 class="entry-backronym">{{ entry.backronym }}</h2>
<p class="entry-tagline">{{ entry.tagline }}</p>
{% if entry.topics %}
<div class="entry-topics">
{% for topic in entry.topics %}
<span class="topic-badge">{{ topic }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="empty">Noch keine Pivots. Komm morgen wieder.</p>
{% endif %}
</div>
</main>
<footer class="footer">
<div class="container footer-inner">
<span>© MOBEA Archiv</span>
<nav class="footer-links">
<a href="/">Zurück zur Startseite</a>
<a href="/echt-jetzt/">Echt jetzt</a>
</nav>
</div>
</footer>
</body>
</html>
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
import random
from datetime import date
TOPICS = [
{
"name": "Tech-Buzzwords",
"hint": "KI, Blockchain, Quantum, Web3, Synergie alles buzzword-lastig und völlig ernst",
},
{
"name": "Esoterik",
"hint": "Chakren, Mondphasen, Energiefelder, Schwingungen spirituell-korporativ",
},
{
"name": "Verschwörungstheorien",
"hint": "harmlos-albern, KEINE echten Personen/Gruppen/Ereignisse, eher Tauben-sind-Drohnen-Niveau",
},
{
"name": "Wellness/Biohacking",
"hint": "Optimierung, Detox, Longevity, quantifiziertes Selbst",
},
{
"name": "Coaching/MLM-Sprech",
"hint": "Mindset, Erfolg, passive Einkommensströme, du schaffst das",
},
{
"name": "Greenwashing/Nachhaltigkeit",
"hint": "klimaneutral, CO₂-Kompensation, planet-positive ohne Substanz",
},
{
"name": "Pet-Tech",
"hint": "Smart Collar, KI-Hundefutter, quantifizierte Haustier-Liebe",
},
{
"name": "Behörden-Digitalisierung",
"hint": "E-Akte, Bürgerportal, digitale Souveränität bürokratisch-absurd",
},
{
"name": "Dating",
"hint": "Matching-Algorithmen, Love-as-a-Service, Swipe-Optimierung",
},
{
"name": "Krypto",
"hint": "DeFi, Tokenomics, Web3-Infrastruktur revolutionär und ernst",
},
]
def pick_topics(day: date) -> list[dict]:
"""Return 12 topic dicts, deterministic for the given UTC date."""
rng = random.Random(day.isoformat())
count = rng.choice([1, 2])
return rng.sample(TOPICS, count)
+174
View File
@@ -0,0 +1,174 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
# Gzip Settings
gzip on; # Enabled gzip
gzip_disable "msie6"; # no gzip for IE6
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types
application/atom+xml
application/javascript
application/json
application/ld+json
application/manifest+json
application/rss+xml
application/vnd.geo+json
application/vnd.ms-fontobject
application/x-font-ttf
application/x-web-app-manifest+json
application/xhtml+xml
application/xml
font/opentype
image/bmp
image/svg+xml
image/x-icon
text/cache-manifest
text/css
text/plain
text/vcard
text/vnd.rim.location.xloc
text/vtt
text/x-component
text/x-cross-domain-policy;
server_tokens off; # Hide Nginx version for security
server {
listen 80;
listen [::]:80;
# For SSL, uncomment and configure the lines below
# listen 443 ssl http2;
# listen [::]:443 ssl http2;
# ssl_certificate /path/to/your/fullchain.pem; # EDIT THIS
# ssl_certificate_key /path/to/your/privkey.pem; # EDIT THIS
# Recommended SSL settings
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_prefer_server_ciphers on;
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
# ssl_session_cache shared:SSL:10m;
# ssl_session_timeout 1d;
# ssl_session_tickets off;
# HSTS (ngx_http_headers_module is required) (63072000 seconds = 2 years)
# add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# OCSP Stapling
# ssl_stapling on;
# ssl_stapling_verify on;
# resolver 8.8.8.8 8.8.4.4 valid=300s; # Use your preferred DNS resolvers
# resolver_timeout 5s;
server_name mobea.de www.mobea.de;
root /usr/share/nginx/html;
index index.html;
location = /favicon.ico {
log_not_found off;
access_log off;
}
location = /robots.txt {
allow all;
log_not_found off;
access_log off;
}
# Cache static assets
location ~* \.(?:css|js)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable, no-transform";
access_log off;
}
location ~* \.(?:jpg|jpeg|gif|png|webp|svg|ico|woff|woff2|ttf|eot|otf)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable, no-transform";
access_log off;
# Hotlink prevention
# Allow your own domain, subdomains, common search engines, and direct access (none)
# You can add more domains to the list if needed, e.g. ~.anothercdn.com
valid_referers none blocked server_names
~\.google\. ~.bing\. ~.yahoo\. ~.duckduckgo\.; # Search engines
if ($invalid_referer) {
# Consider serving a placeholder image instead of 403 if preferred
# rewrite ^/images/(.*)$ /images/hotlink-placeholder.png last;
return 403; # Forbidden
}
}
# Prevent access to .hidden files and directories (e.g., .git)
location ~ /\. {
deny all;
log_not_found off;
access_log off;
}
# Deny access to .sh files
location ~* \.sh$ {
deny all;
log_not_found off;
access_log off;
}
# Deny access to .md files (like TODO.md)
location ~* \.md$ {
deny all;
log_not_found off;
access_log off;
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, must-revalidate";
}
# Error pages (optional, create these files or Nginx uses defaults)
# error_page 403 /403.html;
# error_page 404 /404.html;
# location = /40x.html { # A generic page for 403 and 404
# root /var/www/errors; # Path to your error pages
# internal;
# }
# error_page 500 502 503 504 /50x.html;
# location = /50x.html {
# root /var/www/errors; # Path to your error pages
# internal;
# }
}
include /etc/nginx/conf.d/*.conf;
}
+244
View File
@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Echt jetzt? MOBEA</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #12141a;
--bg-soft: #1a1d26;
--text: #e4e6eb;
--text-muted: #9aa0ad;
--accent: #c9a227;
--accent-hover: #ddb83a;
--border: rgba(255, 255, 255, 0.08);
--max-width: 42rem;
}
html {
font-size: 100%;
-webkit-text-size-adjust: 100%;
}
body {
min-height: 100vh;
background: var(--bg);
background-image:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(201, 162, 39, 0.07), transparent),
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(255, 255, 255, 0.02), transparent);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 1.0625rem;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: var(--max-width);
margin: 0 auto;
padding: 3rem 1.5rem 4rem;
}
.curtain-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 2rem;
opacity: 0.9;
}
h1 {
font-size: clamp(1.875rem, 5vw, 2.5rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.02em;
margin-bottom: 1.75rem;
}
.lead {
font-size: 1.125rem;
color: var(--text-muted);
margin-bottom: 3rem;
}
.lead strong {
color: var(--text);
font-weight: 600;
}
section {
margin-bottom: 2.75rem;
}
section h2 {
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.875rem;
}
section p {
color: var(--text-muted);
}
section p + p {
margin-top: 0.875rem;
}
.archive-block {
background: var(--bg-soft);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.75rem 1.5rem;
margin-bottom: 2.75rem;
}
.archive-block h2 {
font-size: 1.25rem;
font-weight: 600;
text-transform: none;
letter-spacing: -0.01em;
color: var(--text);
margin-bottom: 0.625rem;
}
.archive-block p {
font-size: 0.9375rem;
color: var(--text-muted);
margin-bottom: 1.25rem;
}
.actions {
display: flex;
flex-direction: column;
gap: 0.875rem;
margin-bottom: 3rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1.375rem;
font-size: 0.9375rem;
font-weight: 600;
text-decoration: none;
border-radius: 0.5rem;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: var(--accent);
color: #1a1508;
border: 1px solid var(--accent);
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
}
footer {
padding-top: 2rem;
border-top: 1px solid var(--border);
}
footer p {
font-size: 0.8125rem;
color: var(--text-muted);
line-height: 1.6;
}
@media (min-width: 480px) {
.wrap {
padding: 4rem 2rem 5rem;
}
.actions {
flex-direction: row;
flex-wrap: wrap;
}
.btn {
flex: 1 1 auto;
}
}
</style>
</head>
<body>
<div class="wrap">
<p class="curtain-badge" aria-hidden="true">🎭 Hinter dem Vorhang</p>
<h1>Oh. Du hast das wirklich geglaubt?</h1>
<p class="lead">
<strong>MOBEA ist kein Startup.</strong> MOBEA ist jeden Tag ein anderes Startup
eine täglich von einer KI neu erfundene, todernst vorgetragene Fake-Firma.
Es gibt nichts zu kaufen, niemanden zu kontaktieren, und das Impressum führt …
nun ja, hierher.
</p>
<section aria-labelledby="how-heading">
<h2 id="how-heading">Wie funktioniert das?</h2>
<p>
Eine KI würfelt jeden Tag Themen Tech-Buzzwords, Esoterik, harmlose Verschwörungen,
Coaching-Sprech und was sonst noch im Buzzword-Bingo steht und erfindet dazu ein
Startup samt Preisen, Testimonials und Kennzahlen. Alles frei erfunden.
</p>
<p>
Jede Ähnlichkeit mit echten Startups ist rein zufällig, aber statistisch
schwer vermeidbar. 😬
</p>
</section>
<aside class="archive-block" aria-labelledby="archive-heading">
<h2 id="archive-heading">Unsere bisherigen Pivots</h2>
<p>
Jeden Tag ein neues Gesicht und irgendwo liegt das Archiv aller
vergangenen Inkarnationen. Diese Seite ist der einzige Ort, der dorthin verlinkt.
</p>
<a class="btn btn-primary" href="/archiv">Zum geheimen Archiv</a>
</aside>
<nav class="actions" aria-label="Navigation">
<a class="btn btn-secondary" href="/">Zurück zum heutigen Startup morgen sind wir wer anderes.</a>
</nav>
<footer>
<p>
Kein echtes Impressum nötig? Doch: MOBEA ist ein privates Satireprojekt
ohne Tracking, ohne Cookies, ohne Datensammlung.
Die Cookie-Banner sind übrigens auch Satire. Wir speichern nichts.
Nicht mal deine Enttäuschung.
</p>
</footer>
</div>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Disallow: /archiv
Disallow: /echt-jetzt