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
+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)