From: Claude Code AI Date: Thu, 25 Jun 2026 10:01:25 +0000 (+0200) Subject: Social-Asset-Generator (Phase 2) implementiert X-Git-Url: https://git.laktatnebel.de/?a=commitdiff_plain;h=1005d4ec32bcbe13323cd56470e47d4ff07294ae;p=websitegenerator.git Social-Asset-Generator (Phase 2) implementiert Neue Klassen in package .social: - SocialSlide (Record): Typen HERO, SECTION, CTA + Heading/Subtext/Hero - SocialGenerator: Orchestrierung pro Artikel - h2-Überschriften aus Markdown-Body extrahieren - {social}-Marker: nur markierte Überschriften nutzen (max. 6) - Ohne Marker: erste 6 Überschriften - Hero-Crops erzeugen (4:5, 9:16, 1:1) - Slides planen: Slide 1 = Hero+Titel, Folge-Slides = Abschnitte, letzter = CTA - caption.txt: Hook-Platzhalter + Excerpt + Hashtags + Link - SlideRenderer: ImageMagick-Pipeline - Hero auf Zielformat croppen (fill + center) - SVG-Template proportional auf 1080px Breite skalieren - SVG als Overlay am unteren Rand kompositionieren (Deko-Balken sichtbar) - Text per -annotate mit Zeilenumbruch-Hilfsmethode SiteConfig: neue Felder socialOutputPath, socialTemplate, socialHashtags, socialFont (aus paths/social_assets in config.yaml) Main: --emit-social Flag verdrahtet SPEC.md: Phase 2 + Roadmap ergänzt (Social-Asset-Generator, TOC-Bug, Kandidaten) Co-Authored-By: Claude Sonnet 4.6 --- diff --git a/SPEC.md b/SPEC.md index b7ee564..28e196b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -245,3 +245,165 @@ meta_*, canonical_url, strikte Bild-Konventionen + Validierung. 4. Test gegen echtes Schema (db/schema.sql) mit lokaler PostgreSQL: generiertes SQL muss fehlerfrei durchlaufen und korrekt verknüpfen. 5. Lint-Ausgabe vervollständigen. + +--- + +# SPEC (Phase 2): Social-Asset-Generator (Instagram via Buffer) + +Status: Design abgeschlossen, Implementierung offen. +Reihenfolge: NACH dem Markdown-Loader. Baut auf der content/blog//- +Struktur auf – sobald Artikel mit Hero + Überschriften vorliegen, ist +dies nur ein weiterer Output-Schritt über denselben Daten. + +## Zweck + +Das manuelle Zusammenschieben von Instagram-Posts am Handy entfällt. +Der Build-Lauf erzeugt fertige Bild-Assets + Caption-Text pro Artikel. +Der Redakteur lädt diese am Rechner in Buffer.com hoch – KEINE direkte +Instagram-Integration, keine API, kein OAuth. Buffer übernimmt Timing +und Posting. Lokal-first: Dateien raus, Mensch behält Kontrolle. + +WICHTIG: Speichern ≠ Posten (anders als bei der Website). Das Posten +bleibt bewusst manuell, weil Social Timing und Kontext braucht. + +## Scope Phase 2 + +Nur KARUSSELL-Posts. Das ist der Sweet Spot: +- Artikelstruktur (Hero + ## Überschriften) = Karussellstruktur, fast + vollständig automatisierbar. +- Story: später, teilautomatisierbar, dünner Mehrwert. +- Reel: NICHT automatisieren. Statisches Hero + Text als Pseudo-Video + wird als Bot-Content erkannt und vom Algorithmus bestraft. Reels + brauchen echtes Bewegtbild. + +## Output-Layout + +``` +social// +├── slide_1.png Hero + Titel-Overlay +├── slide_2.png erste ## Überschrift +├── slide_3.png zweite ## Überschrift +├── ... +├── slide_N.png CTA "Ganzen Artikel lesen → Link in Bio" +├── hero_1x1.png Hero zugeschnitten 1080×1080 +├── hero_4x5.png Hero zugeschnitten 1080×1350 +├── hero_9x16.png Hero zugeschnitten 1080×1920 (Story-Format) +└── caption.txt Hook-Vorschläge + excerpt + Hashtags +``` + +## Slide-Erzeugung + +- Slides sind HTML/SVG mit den bestehenden CSS-Design-Tokens + (--deep, --teal, --yellow, --mint, --color-primary, --color-accent), + zu PNG gerastert. Gleiches Muster wie die Website-Templates, nur + anderes Zielformat → Markenbild bleibt automatisch konsistent. +- Format: 1080×1350 (4:5) für Karussell – beste IG-Sichtbarkeit. +- Slide 1: Hero-Bild als Hintergrund, Titel als Overlay (Barlow Condensed, + wie page-hero-title). +- Slide 2..N-1: je eine ## Überschrift, ggf. mit erstem Satz darunter. +- Slide N: CTA mit Logo + "Link in Bio". + +## Slide-Anzahl begrenzen (DESIGN-ENTSCHEIDUNG, offen) + +IG deckelt bei 10 Slides, Aufmerksamkeit bei deutlich weniger. +Richtwert: Hero-Slide + max. 5–6 Überschriften-Slides + CTA. + +Bei Artikeln mit mehr ## Überschriften – zwei Optionen (zu entscheiden): +- a) Generator nimmt die ersten N Überschriften. +- b) Im Markdown markieren welche Überschriften "social-relevant" sind + (z.B. ## Titel {social} oder eine Frontmatter-Liste). + +Analog zur TOC-Hierarchie-Frage: nicht sofort lösen, aber festlegen +bevor gebaut wird. + +## Caption (halbautomatisch) + +caption.txt enthält: +- 3 vom LLM vorgeschlagene HOOKS (erste Caption-Zeile) – der Mensch wählt. + Der Hook ist der eine Teil den kein Template aus dem Artikel ziehen kann: + "Warum einen Coach?" ist ein guter Artikeltitel, aber ein schlechter + IG-Hook. Einsatz für Claude-in-Claude-API: 3 Varianten generieren. +- excerpt als Caption-Körper. +- Hashtags aus den tags (+ feste Marken-Hashtags aus config). + +Hook NICHT automatisch final setzen – das ist der eine Schritt zu viel, +der aus "spart Zeit" ein "klingt nach Bot" macht. + +## Architektur-Einordnung + +- Neuer Output-Schritt im Generator, KEIN neuer Stack. +- Gleiche content/blog//-Eingabe, Output in social//. +- Hero-Crops nutzen den bestehenden ImageProcessor (ImageMagick). +- Slide-Rendering: HTML→PNG (z.B. headless Chromium oder bestehende + SVG-Pipeline). Render-Technik teilweise schon vorhanden. + +## Voraussetzung + +Setzt den Markdown-Loader (Phase 1) voraus: sauberes Hero +(hero__1) und ## Überschriften müssen strukturiert vorliegen. +Erst Loader, dann Social-Bäcker. + +--- + +# Roadmap-Einordnung + +Diese SPEC beschreibt zwei aufeinander aufbauende Phasen. Hier ihr Platz +im Gesamtbild des Projekts. Reihenfolge ist bindend: Phase 2 setzt Phase 1 +voraus. + +## Bereits erledigt (v0.1.0) + +- Vollständige Seitenstruktur: Start, Coaching, Blog, Camps, Tools, Kontakt +- Kontaktformular + Event-Anmeldung über Postfix (DKIM/SPF/DMARC, -f Envelope) +- Event-System mit Galerien (masonry/carousel/slideshow) + Lightbox +- SEO: JSON-LD Schemas, Meta-Descriptions, Breadcrumbs +- Responsive Bildverarbeitung (WebP, 3 Größen), EXIF-auto-orient +- CSS-Refactoring, event.ftlh modularisiert (Freemarker-Macros) +- Markdown-Loader v1 (Hero einzeln + Tags) – Grundgerüst steht + +## Phase 1 – Markdown-Blog-Loader (NÄCHSTER SCHRITT) + +Siehe oberen Teil dieser SPEC. Erweitert den v1-Loader auf: +mehrere Heroes (Carousel), Card, Gallery, hero_style, unpublish, +meta_*, canonical_url, strikte Bild-Konventionen + Validierung. +→ Blog-Artikel als content/blog//artikel.md statt handgeschriebenem SQL. + +## Phase 2 – Social-Asset-Generator + +Siehe oberen Teil dieser SPEC. Karussell-PNGs + caption.txt nach +social// für Upload in Buffer. Setzt Phase 1 voraus. + +## Phase 3+ – Weitere Kandidaten (Priorität offen, durch OWNER zu setzen) + +Aus den bisherigen Sessions als Kandidaten notiert – NICHT priorisiert, +Owner entscheidet: + +- TOC-Hierarchie-Bug: Inhaltsverzeichnis in Blog-Artikeln nummeriert + flach durch, ignoriert h2/h3-Ebenen. Kleiner main.js-Fix + CSS. + (Guter erster Task zum Ausprobieren von Claude Code.) +- Eingebettete Bilder im Markdown: AttributeProvider im MarkdownRenderer, + der site.contextPath vor src setzt. +- Lightbox-Verbesserungen (Pfeil-Navigation in der Galerie). +- RSS-Feed für Blog. +- Such-Funktion (clientseitig, z.B. Lunr.js). +- Server-Härtung: fail2ban, unattended-upgrades, Apache-Hardening. +- Story-Format für Social (Phase 2 erweitern). +- Loader für weitere Inhaltstypen (Events, Pages) – aktuell bewusst + SQL-only; nur falls Pflegeaufwand es rechtfertigt. + +## Langfristig / Kommerzialisierung + +- Mehrsprachigkeit (EN) – im Datenmodell aktuell nicht vorgesehen. +- Web-UI als optionaler Aufsatz über dem Markdown-Format (Decap-CMS-artig). + Markdown bleibt kanonisch, UI ist nur Editor. Datenmodell muss dafür + NICHT angefasst werden. +- Zielgruppe: technisch interessierte Nicht-Developer (Webagenturen, + digitale Nomaden). Differenzierung gegenüber WordPress/Jekyll: + lokal-first, geräte-souverän, "Speichern = veröffentlichen", kein + Plugin-Höllenloch. + +## Owner-Hoheit + +Offene Punkte, bekannte Fehler und die endgültige Roadmap-Priorisierung +pflegt der Owner. Obige Phase-3-Liste ist Vorschlag/Gedächtnisstütze +aus den Entwicklungs-Sessions, keine festgelegte Reihenfolge. diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/Main.java b/src/main/java/de/laktatnebel/product/websitegenerator/Main.java index 63a7729..9c97582 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/Main.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/Main.java @@ -3,6 +3,7 @@ package de.laktatnebel.product.websitegenerator; import de.laktatnebel.product.websitegenerator.config.SiteConfig; import de.laktatnebel.product.websitegenerator.generator.SiteGenerator; import de.laktatnebel.product.websitegenerator.loader.ContentBuild; +import de.laktatnebel.product.websitegenerator.social.SocialGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,6 +18,7 @@ import java.nio.file.Path; * java -jar ssg.jar --dry-run → ohne Dateien zu schreiben * java -jar ssg.jar --emit-content → Markdown-Artikel zu SQL generieren (db/articles/_generated/) * java -jar ssg.jar --check-content → Markdown-Artikel prüfen (Lint, schreibt nichts) + * java -jar ssg.jar --emit-social → Social-Assets erzeugen (social/<slug>/) * * Die Content-Modi (--emit-content / --check-content) laufen eigenständig * und starten NICHT den Generator – sie sind als separater Schritt im @@ -34,6 +36,7 @@ public class Main { boolean dryRun = false; boolean emitContent = false; boolean checkContent = false; + boolean emitSocial = false; for (String arg : args) { switch (arg) { @@ -49,6 +52,10 @@ public class Main { checkContent = true; log.info("Modus: check-content (Lint, schreibt nichts)"); } + case "--emit-social" -> { + emitSocial = true; + log.info("Modus: emit-social (Social-Assets erzeugen)"); + } default -> configPath = Path.of(arg); } } @@ -64,6 +71,29 @@ public class Main { return; } + // ── Social-Modus: Social-Assets erzeugen ───────────────────── + if (emitSocial) { + if (config.socialTemplate == null) { + log.error("paths.social_template fehlt in config.yaml – abgebrochen."); + System.exit(1); + } + try { + int count = new SocialGenerator( + config.contentPath, + config.socialOutputPath, + config.socialTemplate, + config.baseUrl, + config.socialHashtags, + config.socialFont + ).run(); + log.info("Social-Lauf abgeschlossen: {} Artikel verarbeitet", count); + } catch (Exception e) { + log.error("Social-Lauf fehlgeschlagen: {}", e.getMessage(), e); + System.exit(1); + } + return; + } + // ── Content-Modus: Markdown → SQL (ohne Generator) ──────────── if (emitContent || checkContent) { try { diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java b/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java index 237df04..62ff310 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Map; /** @@ -37,8 +38,12 @@ public class SiteConfig { public final String templatesPath; public final Path staticPath; public final String dbSchema; - public final Path contentPath; // Markdown-Quelle (content/) - public final Path articlesPath; // Ziel für generierte SQLs (db/articles/) + public final Path contentPath; // Markdown-Quelle (content/) + public final Path articlesPath; // Ziel für generierte SQLs (db/articles/) + public final Path socialOutputPath; // Ziel für Social-Assets (social/) + public final Path socialTemplate; // SVG-Template für Slides + public final List socialHashtags; // Feste Marken-Hashtags + public final String socialFont; // ImageMagick-Fontname für Slide-Text // Blog public final double navTagCoverage; @@ -98,6 +103,17 @@ public class SiteConfig { dbSchema = (String) db.getOrDefault("schema", "public"); contentPath = Path.of((String) paths.getOrDefault("content", "content")); articlesPath = Path.of((String) paths.getOrDefault("articles", "db/articles")); + socialOutputPath = Path.of((String) paths.getOrDefault("social", "social")); + String st = (String) paths.getOrDefault("social_template", null); + socialTemplate = st != null ? Path.of(st) : null; + + Map socialCfg = raw.containsKey("social_assets") + ? (Map) raw.get("social_assets") : Map.of(); + Object hashtagsRaw = socialCfg.get("hashtags"); + socialHashtags = hashtagsRaw instanceof List hl + ? hl.stream().map(Object::toString).toList() + : List.of(); + socialFont = (String) socialCfg.getOrDefault("font", "DejaVu-Sans-Bold"); // Blog Map blog = raw.containsKey("blog") ? (Map) raw.get("blog") : Map.of(); diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/social/SlideRenderer.java b/src/main/java/de/laktatnebel/product/websitegenerator/social/SlideRenderer.java new file mode 100644 index 0000000..4266b25 --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/social/SlideRenderer.java @@ -0,0 +1,225 @@ +package de.laktatnebel.product.websitegenerator.social; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.*; +import java.util.ArrayList; +import java.util.List; + +/** + * Rendert einzelne Slides und Hero-Crops via ImageMagick. + * + * Pipeline pro Slide (1080×1350, 4:5): + * 1. Hero-Bild auf 1080×1350 croppen (fill + center) + * 2. SVG-Template proportional auf 1080px Breite skalieren (→ 1080×1920) + * 3. SVG-PNG am unteren Rand über Foto kompositionieren (Deko-Balken sichtbar) + * 4. Dunkel-Gradient für Lesbarkeit + Text-Annotation + */ +public final class SlideRenderer { + + private static final Logger log = LoggerFactory.getLogger(SlideRenderer.class); + + // Karussell: 4:5 + public static final int CAROUSEL_W = 1080; + public static final int CAROUSEL_H = 1350; + + // Story: 9:16 + public static final int STORY_W = 1080; + public static final int STORY_H = 1920; + + // Square: 1:1 + public static final int SQUARE_W = 1080; + public static final int SQUARE_H = 1080; + + // SVG-Template-Originalhöhe (1080×1920) + private static final int SVG_NATIVE_H = 1920; + + private final Path svgTemplate; + private final String font; + private Path cachedSvgPng; + + public SlideRenderer(Path svgTemplate, String font) { + this.svgTemplate = svgTemplate; + this.font = font != null ? font : "DejaVu-Sans-Bold"; + } + + // ── Hero-Crops ─────────────────────────────────────────────── + + public void cropHero(Path heroImage, Path outDir) throws IOException, InterruptedException { + Files.createDirectories(outDir); + crop(heroImage, outDir.resolve("hero_4x5.png"), CAROUSEL_W, CAROUSEL_H); + crop(heroImage, outDir.resolve("hero_9x16.png"), STORY_W, STORY_H); + crop(heroImage, outDir.resolve("hero_1x1.png"), SQUARE_W, SQUARE_H); + } + + private void crop(Path src, Path dst, int w, int h) throws IOException, InterruptedException { + run(cmd("magick", + src.toString(), + "-auto-orient", + "-resize", w + "x" + h + "^", + "-gravity", "Center", + "-extent", w + "x" + h, + dst.toString())); + } + + // ── Slide rendern ──────────────────────────────────────────── + + public void renderSlide(SocialSlide slide, int index, Path outDir) + throws IOException, InterruptedException { + Files.createDirectories(outDir); + Path out = outDir.resolve("slide_" + index + ".png"); + + Path bg = buildBackground(slide.heroImage(), outDir); + Path base = composite(bg, outDir, CAROUSEL_W, CAROUSEL_H); + annotateText(base, slide, out); + + // Temporäre Zwischendateien aufräumen + Files.deleteIfExists(bg); + if (!base.equals(out)) Files.deleteIfExists(base); + log.debug("→ {}", out.getFileName()); + } + + /** Foto auf 1080×1350 croppen → temporäre Datei */ + private Path buildBackground(Path heroImage, Path tmpDir) + throws IOException, InterruptedException { + Path bg = tmpDir.resolve("_bg_tmp.png"); + crop(heroImage, bg, CAROUSEL_W, CAROUSEL_H); + return bg; + } + + /** + * SVG-Overlay über Foto kompositionieren. + * SVG wird auf 1080px Breite skaliert (→ 1920px hoch), + * dann am unteren Rand des 1350px-Canvas ausgerichtet. + */ + private Path composite(Path bg, Path tmpDir, int w, int h) + throws IOException, InterruptedException { + Path svgPng = getSvgPng(tmpDir); + Path out = tmpDir.resolve("_comp_tmp.png"); + + // Offset: SVG-Boden = Canvas-Boden → svgTop = h - SVG_NATIVE_H + int yOffset = h - SVG_NATIVE_H; // negativ bei 4:5: 1350-1920 = -570 + + run(cmd("magick", bg.toString(), + svgPng.toString(), + "-geometry", "+0" + yOffset, + "-composite", + out.toString())); + return out; + } + + /** SVG einmalig in PNG umwandeln und cachen (auf 1080px Breite skaliert). */ + private Path getSvgPng(Path tmpDir) throws IOException, InterruptedException { + if (cachedSvgPng != null && Files.exists(cachedSvgPng)) return cachedSvgPng; + Path out = tmpDir.resolve("_svg_overlay.png"); + run(cmd("magick", svgTemplate.toString(), + "-resize", CAROUSEL_W + "x", // Breite fixieren, Höhe proportional + out.toString())); + cachedSvgPng = out; + return out; + } + + /** Text auf das kompositionierte Bild schreiben. */ + private void annotateText(Path base, SocialSlide slide, Path out) + throws IOException, InterruptedException { + + List args = new ArrayList<>(); + args.add("magick"); + args.add(base.toString()); + + switch (slide.type()) { + case HERO -> { + // Großer Titel, mittig-unten + addTextArgs(args, slide.heading(), 80, "-gravity", "South", "+0+250"); + if (slide.subtext() != null) { + addTextArgs(args, slide.subtext(), 42, "-gravity", "South", "+0+170"); + } + } + case SECTION -> { + // Überschrift + optionaler erster Satz + addTextArgs(args, slide.heading(), 72, "-gravity", "South", "+0+250"); + if (slide.subtext() != null) { + addTextArgs(args, slide.subtext(), 38, "-gravity", "South", "+0+170"); + } + } + case CTA -> { + addTextArgs(args, slide.heading(), 60, "-gravity", "Center", "+0+0"); + } + } + + args.add(out.toString()); + run(args.toArray(new String[0])); + } + + private void addTextArgs(List args, String text, + int size, String... gravity) { + String wrapped = wrapText(text, 28); + for (String a : gravity) args.add(a); + args.addAll(List.of( + "-font", font, + "-pointsize", String.valueOf(size), + "-fill", "white", + "-stroke", "#00000088", + "-strokewidth", "2", + "-annotate", gravity[gravity.length - 1], // letzte gravity-Option = offset + wrapped + )); + } + + // ── ImageMagick-Aufruf ─────────────────────────────────────── + + private void run(String... cmd) throws IOException, InterruptedException { + // Versuche erst "magick" (ImageMagick 7), dann "convert" (6) + String[] actualCmd = cmd; + if ("magick".equals(cmd[0])) { + try { + Process check = new ProcessBuilder("magick", "-version") + .redirectErrorStream(true).start(); + if (check.waitFor() != 0) { + actualCmd = cmd.clone(); + actualCmd[0] = "convert"; + } + } catch (IOException ignored) { + actualCmd = cmd.clone(); + actualCmd[0] = "convert"; + } + } + + ProcessBuilder pb = new ProcessBuilder(actualCmd); + pb.redirectErrorStream(true); + Process proc = pb.start(); + String out = new String(proc.getInputStream().readAllBytes()); + int exit = proc.waitFor(); + if (exit != 0) { + throw new IOException("ImageMagick Fehler (exit " + exit + "): " + out + + "\nKommando: " + String.join(" ", actualCmd)); + } + } + + private String[] cmd(String... parts) { return parts; } + + // ── Hilfsmethoden ──────────────────────────────────────────── + + /** Einfacher Zeilenumbruch bei maxCharsPerLine Zeichen (an Wortgrenzen). */ + static String wrapText(String text, int maxCharsPerLine) { + if (text == null) return ""; + if (text.length() <= maxCharsPerLine) return text; + StringBuilder sb = new StringBuilder(); + String[] words = text.split("\\s+"); + int lineLen = 0; + for (String word : words) { + if (lineLen > 0 && lineLen + 1 + word.length() > maxCharsPerLine) { + sb.append('\n'); + lineLen = 0; + } else if (lineLen > 0) { + sb.append(' '); + lineLen++; + } + sb.append(word); + lineLen += word.length(); + } + return sb.toString(); + } +} diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/social/SocialGenerator.java b/src/main/java/de/laktatnebel/product/websitegenerator/social/SocialGenerator.java new file mode 100644 index 0000000..7b63c7c --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/social/SocialGenerator.java @@ -0,0 +1,242 @@ +package de.laktatnebel.product.websitegenerator.social; + +import de.laktatnebel.product.websitegenerator.loader.ContentLoader; +import de.laktatnebel.product.websitegenerator.loader.LoadedArticle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.regex.*; +import java.util.stream.Collectors; + +/** + * Erzeugt Social-Media-Assets für alle Blog-Artikel: + * + * social/<slug>/ + * slide_1.png – Hero + Titel + * slide_2.png – erste h2-Überschrift + * ... + * slide_N.png – CTA + * hero_4x5.png – Hero zugeschnitten 1080×1350 + * hero_9x16.png – Hero zugeschnitten 1080×1920 + * hero_1x1.png – Hero zugeschnitten 1080×1080 + * caption.txt – Hook-Vorschläge + Excerpt + Hashtags + * + * Aufgerufen mit --emit-social aus Main. + */ +public final class SocialGenerator { + + private static final Logger log = LoggerFactory.getLogger(SocialGenerator.class); + + private static final int MAX_SECTION_SLIDES = 6; + private static final String SOCIAL_MARKER = "{social}"; + private static final Pattern H2_PATTERN = + Pattern.compile("^##\\s+(.+)$", Pattern.MULTILINE); + + private final Path contentRoot; + private final Path socialRoot; + private final Path svgTemplate; + private final String baseUrl; + private final List fixedHashtags; + private final String font; + + public SocialGenerator(Path contentRoot, Path socialRoot, Path svgTemplate, + String baseUrl, List fixedHashtags, String font) { + this.contentRoot = contentRoot; + this.socialRoot = socialRoot; + this.svgTemplate = svgTemplate; + this.baseUrl = baseUrl; + this.fixedHashtags = fixedHashtags != null ? fixedHashtags : List.of(); + this.font = font; + } + + public int run() throws IOException { + if (!Files.exists(svgTemplate)) { + log.error("SVG-Template nicht gefunden: {}", svgTemplate); + return 0; + } + + ContentLoader loader = new ContentLoader(true); + List articles = loader.loadBlogArticles(contentRoot); + if (articles.isEmpty()) { + log.info("Keine Artikel gefunden – Social-Generator übersprungen."); + return 0; + } + + SlideRenderer renderer = new SlideRenderer(svgTemplate, font); + int generated = 0; + + for (LoadedArticle article : articles) { + try { + generate(article, renderer); + generated++; + } catch (Exception e) { + log.error("✗ {}: {}", article.slug(), e.getMessage(), e); + } + } + + log.info("Social-Assets erzeugt: {} Artikel", generated); + return generated; + } + + // ── Pro Artikel ────────────────────────────────────────────── + + private void generate(LoadedArticle article, SlideRenderer renderer) + throws IOException, InterruptedException { + + String folderSlug = article.slug().replace("blog/", ""); + Path outDir = socialRoot.resolve(folderSlug); + Files.createDirectories(outDir); + + Path heroImage = resolveHeroImage(article); + if (heroImage == null) { + log.warn("⚠ {}: kein Hero-Bild → Social-Assets übersprungen", article.slug()); + return; + } + + renderer.cropHero(heroImage, outDir); + + List slides = planSlides(article, heroImage); + for (int i = 0; i < slides.size(); i++) { + renderer.renderSlide(slides.get(i), i + 1, outDir); + } + + writeCaption(article, outDir); + log.info("✓ {} → {} Slides + hero-crops + caption.txt", article.slug(), slides.size()); + } + + // ── Slides planen ──────────────────────────────────────────── + + private List planSlides(LoadedArticle article, Path heroImage) { + List slides = new ArrayList<>(); + + slides.add(new SocialSlide( + SocialSlide.Type.HERO, + article.title(), + article.subtitle(), + heroImage + )); + + for (HeadingEntry h : selectHeadings(extractHeadings(article.body()))) { + slides.add(new SocialSlide( + SocialSlide.Type.SECTION, + h.text(), + h.firstSentence(), + heroImage + )); + } + + slides.add(new SocialSlide( + SocialSlide.Type.CTA, + "Ganzen Artikel lesen\n→ Link in Bio", + null, + heroImage + )); + + return slides; + } + + private record HeadingEntry(String text, String firstSentence, boolean social) {} + + private List extractHeadings(String body) { + List result = new ArrayList<>(); + String[] lines = body.split("\n", -1); + + for (int i = 0; i < lines.length; i++) { + String line = lines[i].trim(); + Matcher m = H2_PATTERN.matcher(line); + if (!m.matches()) continue; + + String raw = m.group(1).trim(); + boolean social = raw.contains(SOCIAL_MARKER); + String text = raw.replace(SOCIAL_MARKER, "").trim(); + + String firstSentence = null; + for (int j = i + 1; j < lines.length; j++) { + String l = lines[j].trim(); + if (l.isEmpty() || l.startsWith("#") || l.startsWith("!") || l.startsWith("-")) + continue; + String clean = l.replaceAll("[*_`]", "") + .replaceAll("\\[([^\\]]+)\\]\\([^)]+\\)", "$1") + .trim(); + if (!clean.isEmpty()) { + int dot = firstSentenceEnd(clean); + firstSentence = dot >= 0 ? clean.substring(0, dot + 1) : clean; + if (firstSentence.length() > 120) + firstSentence = firstSentence.substring(0, 117) + "…"; + } + break; + } + + result.add(new HeadingEntry(text, firstSentence, social)); + } + return result; + } + + private List selectHeadings(List all) { + boolean anyMarked = all.stream().anyMatch(HeadingEntry::social); + return all.stream() + .filter(h -> !anyMarked || h.social()) + .limit(MAX_SECTION_SLIDES) + .collect(Collectors.toList()); + } + + private int firstSentenceEnd(String s) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '.' || c == '!' || c == '?') return i; + } + return -1; + } + + // ── Caption ────────────────────────────────────────────────── + + private void writeCaption(LoadedArticle article, Path outDir) throws IOException { + StringBuilder sb = new StringBuilder(); + + sb.append("=== HOOK-VORSCHLÄGE (einen wählen und anpassen) ===\n"); + sb.append("(Placeholder – manuell ersetzen oder LLM-Integration aktivieren)\n"); + sb.append("[Hook 1: Frage stellen, z.B. \"Warum ...?\"]\n"); + sb.append("---\n"); + sb.append("[Hook 2: Überraschung/Kontrast, z.B. \"Alle denken X, aber ...\"]\n"); + sb.append("---\n"); + sb.append("[Hook 3: Nutzen, z.B. \"So sparst du ... mit ...\"]\n"); + sb.append("\n"); + + sb.append("=== CAPTION ===\n"); + if (article.excerpt() != null && !article.excerpt().isBlank()) { + sb.append(article.excerpt()).append("\n"); + } else { + sb.append("[Excerpt fehlt – in artikel.md ergänzen]\n"); + } + sb.append("\n"); + + sb.append("=== HASHTAGS ===\n"); + String tags = article.tags().stream() + .map(t -> "#" + t.replace("-", "").replace(" ", "")) + .collect(Collectors.joining(" ")); + String fixed = fixedHashtags.stream() + .map(t -> t.startsWith("#") ? t : "#" + t) + .collect(Collectors.joining(" ")); + sb.append(tags); + if (!tags.isEmpty() && !fixed.isEmpty()) sb.append(" "); + sb.append(fixed).append("\n"); + sb.append("\n"); + + sb.append("=== LINK IN BIO ===\n"); + sb.append(baseUrl).append("/").append(article.slug()).append("\n"); + + Files.writeString(outDir.resolve("caption.txt"), sb.toString()); + } + + // ── Helpers ────────────────────────────────────────────────── + + private Path resolveHeroImage(LoadedArticle article) { + if (article.heroImageFiles().isEmpty()) return null; + String folderSlug = article.slug().replace("blog/", ""); + return contentRoot.resolve("blog").resolve(folderSlug) + .resolve(article.heroImageFiles().get(0)); + } +} diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/social/SocialSlide.java b/src/main/java/de/laktatnebel/product/websitegenerator/social/SocialSlide.java new file mode 100644 index 0000000..a058657 --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/social/SocialSlide.java @@ -0,0 +1,15 @@ +package de.laktatnebel.product.websitegenerator.social; + +import java.nio.file.Path; + +/** + * Eine geplante Instagram-Karussell-Folie. + */ +public record SocialSlide( + Type type, + String heading, // Überschrift (bereinigt, ohne {social}-Marker) + String subtext, // erster Satz nach der Überschrift (optional, null) + Path heroImage // immer das Hero-Bild als Hintergrund +) { + public enum Type { HERO, SECTION, CTA } +}