6. Content-Quellen
Das System benötigt strukturierte Eingangsdaten. Diese Eingangsdaten werden nicht direkt veröffentlicht, sondern zuerst normalisiert und danach vom Content-Generator verarbeitet.
Typische Quellen:
Buchdaten
Autorendaten
Blogartikel
Zitate
Covermotive
manuelle Themenlisten
Datenbankeinträge
CSV-Exporte
Ziel ist ein einheitliches internes Format. Der Generator soll nicht wissen müssen, ob ein Buch aus JSON, CSV, SQLite, WordPress oder einer manuell gepflegten Datei kommt.
6.1 Buchdaten aus JSON
JSON ist für das MVP die einfachste Quelle.
Datei:
data/books.json
Beispiel:
[
{
"id": "book_001",
"title": "Stolz und Vorurteil",
"author": "Jane Austen",
"language": "de",
"category": "klassiker",
"description": "Ein Gesellschaftsroman über Herkunft, Heirat, Stolz und Selbsttäuschung.",
"shop_url": "https://example.com/stolz-und-vorurteil",
"cover_image_path": "storage/images/stolz-und-vorurteil.jpg",
"tags": [
"klassiker",
"gesellschaftsroman",
"jane-austen"
]
},
{
"id": "book_002",
"title": "Das Paradies der Damen",
"author": "Émile Zola",
"language": "de",
"category": "klassiker",
"description": "Ein Roman über Warenhäuser, Konsum, Verführung und wirtschaftlichen Wandel.",
"shop_url": "https://example.com/das-paradies-der-damen",
"cover_image_path": "storage/images/das-paradies-der-damen.jpg",
"tags": [
"klassiker",
"zola",
"gesellschaftsroman",
"warenhaus"
]
}
]
Loader:
import json
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class RawBook:
id: str
title: str
author: str
language: str
category: str
description: str
shop_url: str
cover_image_path: str
tags: list[str]
class JsonBookSource:
def __init__(self, file_path: Path) -> None:
self.file_path = file_path
def load(self) -> list[RawBook]:
if self.file_path.exists() is False:
raise RuntimeError(f"Book JSON file does not exist: {self.file_path}")
with self.file_path.open("r", encoding="utf-8") as file:
payload = json.load(file)
if isinstance(payload, list) is False:
raise RuntimeError("Book JSON must contain a list.")
books: list[RawBook] = []
for item in payload:
if isinstance(item, dict) is False:
raise RuntimeError("Book JSON contains a non-object item.")
tags = item.get("tags")
if isinstance(tags, list) is False:
raise RuntimeError("Book tags must be a list.")
normalized_tags: list[str] = []
for tag in tags:
if isinstance(tag, str) is False:
raise RuntimeError("Book tag must be a string.")
normalized_tags.append(tag)
book = RawBook(
id=self._read_string(item, "id"),
title=self._read_string(item, "title"),
author=self._read_string(item, "author"),
language=self._read_string(item, "language"),
category=self._read_string(item, "category"),
description=self._read_string(item, "description"),
shop_url=self._read_string(item, "shop_url"),
cover_image_path=self._read_string(item, "cover_image_path"),
tags=normalized_tags,
)
books.append(book)
return books
def _read_string(self, item: dict[str, object], key: str) -> str:
value = item.get(key)
if isinstance(value, str) is False:
raise RuntimeError(f"Book field must be a string: {key}")
if value.strip() == "":
raise RuntimeError(f"Book field must not be empty: {key}")
return value
6.2 Buchdaten aus CSV
CSV eignet sich für Exporte aus Shop-Systemen, Tabellenkalkulationen oder Verlagsdatenbanken.
Datei:
data/books.csv
Beispiel:
id,title,author,language,category,description,shop_url,cover_image_path,tags
book_001,Stolz und Vorurteil,Jane Austen,de,klassiker,"Ein Gesellschaftsroman über Herkunft, Heirat, Stolz und Selbsttäuschung.",https://example.com/stolz-und-vorurteil,storage/images/stolz-und-vorurteil.jpg,"klassiker;jane-austen;gesellschaftsroman"
book_002,Das Paradies der Damen,Émile Zola,de,klassiker,"Ein Roman über Warenhäuser, Konsum, Verführung und wirtschaftlichen Wandel.",https://example.com/das-paradies-der-damen,storage/images/das-paradies-der-damen.jpg,"klassiker;zola;warenhaus"
Loader:
import csv
from pathlib import Path
class CsvBookSource:
def __init__(self, file_path: Path) -> None:
self.file_path = file_path
def load(self) -> list[RawBook]:
if self.file_path.exists() is False:
raise RuntimeError(f"Book CSV file does not exist: {self.file_path}")
books: list[RawBook] = []
with self.file_path.open("r", encoding="utf-8", newline="") as file:
reader = csv.DictReader(file)
for row in reader:
tags_value = row.get("tags")
if tags_value is None:
raise RuntimeError("Missing CSV column: tags")
tags: list[str] = []
for raw_tag in tags_value.split(";"):
cleaned_tag = raw_tag.strip()
if cleaned_tag != "":
tags.append(cleaned_tag)
book = RawBook(
id=self._read_csv_string(row, "id"),
title=self._read_csv_string(row, "title"),
author=self._read_csv_string(row, "author"),
language=self._read_csv_string(row, "language"),
category=self._read_csv_string(row, "category"),
description=self._read_csv_string(row, "description"),
shop_url=self._read_csv_string(row, "shop_url"),
cover_image_path=self._read_csv_string(row, "cover_image_path"),
tags=tags,
)
books.append(book)
return books
def _read_csv_string(self, row: dict[str, str | None], key: str) -> str:
value = row.get(key)
if value is None:
raise RuntimeError(f"Missing CSV column: {key}")
cleaned_value = value.strip()
if cleaned_value == "":
raise RuntimeError(f"CSV field must not be empty: {key}")
return cleaned_value
CSV ist praktisch, aber fehleranfälliger als JSON:
Trennzeichen
Anführungszeichen
Zeilenumbrüche
Encoding
leere Felder
Tag-Listen
Für ein robustes System sollten CSV-Daten immer normalisiert und validiert werden, bevor sie in die Content-Pipeline gehen.
6.3 Buchdaten aus Datenbank
Sobald die Buchdaten ohnehin in einer Datenbank liegen, sollte das System sie direkt lesen.
Beispiel mit SQLite:
CREATE TABLE books (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL,
language TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT NOT NULL,
shop_url TEXT NOT NULL,
cover_image_path TEXT NOT NULL,
tags_json TEXT NOT NULL
);
Loader:
import json
import sqlite3
from pathlib import Path
class SqliteBookSource:
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
def load(self) -> list[RawBook]:
if self.database_path.exists() is False:
raise RuntimeError(f"Database does not exist: {self.database_path}")
books: list[RawBook] = []
with sqlite3.connect(self.database_path) as connection:
connection.row_factory = sqlite3.Row
cursor = connection.execute(
"""
SELECT
id,
title,
author,
language,
category,
description,
shop_url,
cover_image_path,
tags_json
FROM books
ORDER BY title ASC
"""
)
rows = cursor.fetchall()
for row in rows:
tags_payload = json.loads(str(row["tags_json"]))
if isinstance(tags_payload, list) is False:
raise RuntimeError("tags_json must contain a list.")
tags: list[str] = []
for tag in tags_payload:
if isinstance(tag, str) is False:
raise RuntimeError("Tag from database must be a string.")
tags.append(tag)
book = RawBook(
id=str(row["id"]),
title=str(row["title"]),
author=str(row["author"]),
language=str(row["language"]),
category=str(row["category"]),
description=str(row["description"]),
shop_url=str(row["shop_url"]),
cover_image_path=str(row["cover_image_path"]),
tags=tags,
)
books.append(book)
return books
Bei einer produktiven Verlagsdatenbank sollte nicht direkt gegen operative Tabellen gearbeitet werden. Besser ist eine Exporttabelle oder View.
Beispiel:
CREATE VIEW social_media_books AS
SELECT
product_id AS id,
title,
author_name AS author,
language,
category,
short_description AS description,
shop_url,
cover_image_path,
tags_json
FROM products
WHERE is_active = 1
AND is_public = 1;
Vorteil:
Die Social-Media-Anwendung sieht nur bereinigte Daten.
Das operative Shopschema bleibt entkoppelt.
Änderungen im Shop zerstören nicht sofort die Content-Pipeline.
6.4 Blogartikel als Quelle
Blogartikel sind sehr gute Quellen, weil sie bereits einen Gedanken enthalten.
Ein Blogartikel kann mehrere Social-Media-Varianten erzeugen:
LinkedIn-Beitrag
Facebook-Beitrag
Newsletter-Absatz
Kurzfassung
Zitatkarte
Kommentarimpuls
Minimalformat:
[
{
"id": "blog_001",
"title": "Warum gemeinfreie Literatur trotzdem verlegerische Arbeit macht",
"url": "https://example.com/blog/gemeinfreie-literatur-verlagsarbeit",
"summary": "Gemeinfreie Texte sind frei verfügbar, aber daraus entsteht noch keine gute digitale Ausgabe.",
"content": "Gemeinfreie Literatur wirkt auf den ersten Blick wie ein Rohstoff ohne Kosten...",
"tags": [
"verlagswerkstatt",
"public-domain",
"ebook-produktion"
],
"published_at": "2026-05-11T08:00:00+02:00"
}
]
Modell:
from dataclasses import dataclass
@dataclass(frozen=True)
class BlogArticle:
id: str
title: str
url: str
summary: str
content: str
tags: list[str]
published_at: str
Loader:
import json
from pathlib import Path
class JsonBlogArticleSource:
def __init__(self, file_path: Path) -> None:
self.file_path = file_path
def load(self) -> list[BlogArticle]:
if self.file_path.exists() is False:
raise RuntimeError(f"Blog article file does not exist: {self.file_path}")
with self.file_path.open("r", encoding="utf-8") as file:
payload = json.load(file)
if isinstance(payload, list) is False:
raise RuntimeError("Blog article JSON must contain a list.")
articles: list[BlogArticle] = []
for item in payload:
if isinstance(item, dict) is False:
raise RuntimeError("Blog article item must be an object.")
tags_payload = item.get("tags")
if isinstance(tags_payload, list) is False:
raise RuntimeError("Blog article tags must be a list.")
tags: list[str] = []
for tag in tags_payload:
if isinstance(tag, str) is False:
raise RuntimeError("Blog article tag must be a string.")
tags.append(tag)
article = BlogArticle(
id=self._read_string(item, "id"),
title=self._read_string(item, "title"),
url=self._read_string(item, "url"),
summary=self._read_string(item, "summary"),
content=self._read_string(item, "content"),
tags=tags,
published_at=self._read_string(item, "published_at"),
)
articles.append(article)
return articles
def _read_string(self, item: dict[str, object], key: str) -> str:
value = item.get(key)
if isinstance(value, str) is False:
raise RuntimeError(f"Blog article field must be a string: {key}")
if value.strip() == "":
raise RuntimeError(f"Blog article field must not be empty: {key}")
return value
Für WordPress kann später eine eigene Quelle ergänzt werden:
WordPress REST API
RSS-Feed
direkter Datenbankexport
manueller JSON-Export
Für das MVP reicht ein JSON-Export.
6.5 Zitate als Quelle
Zitate eignen sich besonders für Klassiker, Instagram, Facebook und gelegentlich LinkedIn.
Wichtig: Zitate sollten nicht isoliert verarbeitet werden. Sie brauchen Kontext.
Datei:
data/quotes.json
Beispiel:
[
{
"id": "quote_001",
"text": "Es ist eine allgemein anerkannte Wahrheit, dass ein alleinstehender Mann im Besitz eines schönen Vermögens nichts dringender braucht als eine Frau.",
"book_id": "book_001",
"author": "Jane Austen",
"work_title": "Stolz und Vorurteil",
"language": "de",
"topic": "gesellschaft",
"commentary": "Der berühmte erste Satz ist weniger romantisch als satirisch: Er stellt Ehe, Geld und gesellschaftliche Erwartung sofort in ein ironisches Verhältnis.",
"tags": [
"klassiker",
"satire",
"gesellschaft"
]
}
]
Modell:
from dataclasses import dataclass
@dataclass(frozen=True)
class Quote:
id: str
text: str
book_id: str
author: str
work_title: str
language: str
topic: str
commentary: str
tags: list[str]
Zitate sollten für Social Media intern erweitert werden:
Zitat
Autor
Werk
kurze Einordnung
Bildmotiv oder Zitatkarte
Shoplink optional
Nicht jeder Zitatpost braucht einen Verkaufslink. Ein dezenter Rhythmus ist besser:
3 Zitatposts ohne Link
1 Zitatpost mit Buchhinweis
6.6 Covermotive als Quelle
Covermotive sind nicht nur Bilddateien, sondern Content-Elemente.
Sie können verwendet werden für:
Instagram-Posts
Facebook-Buchvorstellungen
LinkedIn-Werkstattberichte
Vorher-nachher-Beiträge
Coverreihen
Bildmotive mit kurzer Erläuterung
Datei:
data/cover_images.json
Beispiel:
[
{
"id": "cover_001",
"book_id": "book_001",
"image_path": "storage/images/stolz-und-vorurteil.jpg",
"alt_text": "Künstlerisches Covermotiv zu Stolz und Vorurteil mit einer jungen Frau in historischer Kleidung vor einem englischen Landsitz.",
"style": "klassisch, malerisch, zurückhaltend",
"dominant_topics": [
"gesellschaft",
"liebe",
"englische-literatur"
],
"usable_for": [
"instagram",
"facebook",
"linkedin"
]
}
]
Modell:
from dataclasses import dataclass
@dataclass(frozen=True)
class CoverImage:
id: str
book_id: str
image_path: str
alt_text: str
style: str
dominant_topics: list[str]
usable_for: list[str]
Für jede Bildquelle sollte geprüft werden:
Datei existiert
Dateiformat ist erlaubt
Bildgröße passt zur Plattform
Alt-Text ist vorhanden
Nutzungsrecht ist geklärt
Technischer Check:
from pathlib import Path
class ImageFileValidator:
def __init__(self, root_dir: Path) -> None:
self.root_dir = root_dir
def assert_image_exists(self, image_path: str) -> None:
absolute_path = self.root_dir / image_path
if absolute_path.exists() is False:
raise RuntimeError(f"Image file does not exist: {image_path}")
if absolute_path.is_file() is False:
raise RuntimeError(f"Image path is not a file: {image_path}")
suffix = absolute_path.suffix.lower()
allowed_suffixes = [".jpg", ".jpeg", ".png", ".webp"]
if suffix not in allowed_suffixes:
raise RuntimeError(f"Unsupported image file type: {image_path}")
6.7 Manuelle Themenliste
Eine manuelle Themenliste ist die einfachste Quelle für wiederkehrende Beiträge.
Datei:
data/topics.json
Beispiel:
[
{
"id": "topic_001",
"platform": "linkedin",
"post_type": "workshop",
"title": "Warum alte Texte digitale Nacharbeit brauchen",
"briefing": "Erkläre sachlich, warum gemeinfreie Texte nicht automatisch gute E-Books ergeben. Themen: OCR-Fehler, Typografie, Metadaten, EPUB3.",
"tags": [
"verlagswerkstatt",
"ebook-produktion",
"public-domain"
],
"priority": 80,
"status": "active"
},
{
"id": "topic_002",
"platform": "linkedin",
"post_type": "ai_publishing",
"title": "KI als Werkzeug im Ein-Mann-Verlag",
"briefing": "Beschreibe KI als Produktionshilfe, nicht als Ersatz für verlegerische Auswahl.",
"tags": [
"ki-im-verlag",
"automatisierung",
"verlagssoftware"
],
"priority": 70,
"status": "active"
}
]
Modell:
from dataclasses import dataclass
@dataclass(frozen=True)
class ManualTopic:
id: str
platform: str
post_type: str
title: str
briefing: str
tags: list[str]
priority: int
status: str
Manuelle Themen sind besonders geeignet für LinkedIn, weil dort ein konkreter Gedanke wichtiger ist als reine Produktinformation.
Aus einem Thema kann der Generator mehrere Varianten erzeugen:
Kurzbeitrag
längerer Fachbeitrag
Kommentarimpuls
Blogartikel-Skizze
Newsletter-Absatz
6.8 Normalisierung der Eingangsdaten
Die wichtigste Regel:
Jede Quelle wird zuerst in ein gemeinsames internes Format überführt.
Der Content-Generator sollte nicht direkt mit RawBook, BlogArticle, Quote, CoverImage oder ManualTopic arbeiten. Stattdessen erhält er ein normalisiertes ContentSourceItem.
Modell:
from dataclasses import dataclass
@dataclass(frozen=True)
class ContentSourceItem:
id: str
source_type: str
title: str
body: str
author: str | None
book_title: str | None
url: str | None
image_path: str | None
tags: list[str]
platform_hint: str | None
post_type_hint: str | None
Beispiele:
Buch:
{
"id": "book_001",
"source_type": "book",
"title": "Stolz und Vorurteil",
"body": "Ein Gesellschaftsroman über Herkunft, Heirat, Stolz und Selbsttäuschung.",
"author": "Jane Austen",
"book_title": "Stolz und Vorurteil",
"url": "https://example.com/stolz-und-vorurteil",
"image_path": "storage/images/stolz-und-vorurteil.jpg",
"tags": [
"klassiker",
"gesellschaftsroman"
],
"platform_hint": null,
"post_type_hint": "book_intro"
}
Zitat:
{
"id": "quote_001",
"source_type": "quote",
"title": "Zitat aus Stolz und Vorurteil",
"body": "Es ist eine allgemein anerkannte Wahrheit...",
"author": "Jane Austen",
"book_title": "Stolz und Vorurteil",
"url": "https://example.com/stolz-und-vorurteil",
"image_path": null,
"tags": [
"klassiker",
"satire"
],
"platform_hint": "instagram",
"post_type_hint": "quote"
}
Normalizer für Bücher:
class BookNormalizer:
def normalize(self, book: RawBook) -> ContentSourceItem:
return ContentSourceItem(
id=book.id,
source_type="book",
title=book.title,
body=book.description,
author=book.author,
book_title=book.title,
url=book.shop_url,
image_path=book.cover_image_path,
tags=book.tags,
platform_hint=None,
post_type_hint="book_intro",
)
Normalizer für Blogartikel:
class BlogArticleNormalizer:
def normalize(self, article: BlogArticle) -> ContentSourceItem:
body = article.summary
if body.strip() == "":
body = article.content
return ContentSourceItem(
id=article.id,
source_type="blog_article",
title=article.title,
body=body,
author=None,
book_title=None,
url=article.url,
image_path=None,
tags=article.tags,
platform_hint="linkedin",
post_type_hint="blog_summary",
)
Normalizer für Zitate:
class QuoteNormalizer:
def normalize(self, quote: Quote, shop_url: str | None) -> ContentSourceItem:
body = quote.text + "\n\nEinordnung: " + quote.commentary
return ContentSourceItem(
id=quote.id,
source_type="quote",
title="Zitat aus " + quote.work_title,
body=body,
author=quote.author,
book_title=quote.work_title,
url=shop_url,
image_path=None,
tags=quote.tags,
platform_hint="instagram",
post_type_hint="quote",
)
Normalizer für manuelle Themen:
class ManualTopicNormalizer:
def normalize(self, topic: ManualTopic) -> ContentSourceItem:
return ContentSourceItem(
id=topic.id,
source_type="manual_topic",
title=topic.title,
body=topic.briefing,
author=None,
book_title=None,
url=None,
image_path=None,
tags=topic.tags,
platform_hint=topic.platform,
post_type_hint=topic.post_type,
)
Gemeinsame Source-Auswahl
Die Content-Pipeline kann alle Quellen zusammenführen.
from pathlib import Path
class ContentSourceCollector:
def __init__(self, root_dir: Path) -> None:
self.root_dir = root_dir
def collect(self) -> list[ContentSourceItem]:
items: list[ContentSourceItem] = []
book_source = JsonBookSource(self.root_dir / "data" / "books.json")
book_normalizer = BookNormalizer()
for book in book_source.load():
items.append(book_normalizer.normalize(book))
blog_source = JsonBlogArticleSource(self.root_dir / "data" / "blog_articles.json")
blog_normalizer = BlogArticleNormalizer()
for article in blog_source.load():
items.append(blog_normalizer.normalize(article))
return items
Für ein robustes MVP sollte der Collector fehlende optionale Quellen nicht stillschweigend ignorieren. Besser ist ein bewusstes Setup:
books.json ist Pflicht
topics.json ist Pflicht
quotes.json optional
blog_articles.json optional
cover_images.json optional
Validierung normalisierter Quellen
Vor der Weitergabe an OpenAI wird jedes ContentSourceItem geprüft.
class ContentSourceValidator:
def validate(self, item: ContentSourceItem) -> None:
if item.id.strip() == "":
raise RuntimeError("Content source id must not be empty.")
if item.source_type.strip() == "":
raise RuntimeError("Content source type must not be empty.")
if item.title.strip() == "":
raise RuntimeError("Content source title must not be empty.")
if item.body.strip() == "":
raise RuntimeError("Content source body must not be empty.")
if len(item.tags) == 0:
raise RuntimeError("Content source must have at least one tag.")
for tag in item.tags:
if tag.strip() == "":
raise RuntimeError("Content source tag must not be empty.")
Deduplizierung
Quellen können sich überschneiden.
Beispiel:
Ein Buch erscheint in books.json.
Ein Blogartikel behandelt dasselbe Buch.
Ein Zitat stammt aus demselben Buch.
Ein manuelles Thema verweist ebenfalls darauf.
Das ist nicht falsch, muss aber beim Generieren berücksichtigt werden.
Einfache Deduplizierung:
class ContentSourceDeduplicator:
def deduplicate(self, items: list[ContentSourceItem]) -> list[ContentSourceItem]:
seen_keys: set[str] = set()
result: list[ContentSourceItem] = []
for item in items:
key = item.source_type + ":" + item.id
if key in seen_keys:
continue
seen_keys.add(key)
result.append(item)
return result
Für spätere Dublettenprüfung reicht die ID nicht. Dann sollte zusätzlich mit Textähnlichkeit gearbeitet werden.
Ergebnis dieses Kapitels
Die Content-Quellen sind damit definiert:
Buchdaten können aus JSON, CSV oder Datenbank kommen.
Blogartikel werden als eigene Quelle eingebunden.
Zitate erhalten Kontext und Einordnung.
Covermotive werden als strukturierte Bildquellen behandelt.
Manuelle Themen liefern gezielte Beitragsideen.
Alle Quellen werden normalisiert.
Der Generator arbeitet nur mit ContentSourceItem.
Validierung und Deduplizierung erfolgen vor der KI-Verarbeitung.
0 Kommentare