From 7a234be6528b1f4199d6c6b9e8be64e3cafa4432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Wed, 1 Oct 2025 08:10:09 +0200 Subject: [PATCH] Feature: Automatische Metadaten-Extraktion aus Frontmatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neuer markdown_parser.py mit YAML-Frontmatter Extraktion - Unterstützung für drei Modi: Einzelne URL, YAML-Batch, Forgejo-Repo - Metadaten (name, description, tags, image, author) aus Frontmatter - Schema.org-Support für commonMetadata - Vereinfachte posts.yaml (nur URLs statt vollständiger Metadaten) - Aktualisierte Dokumentation (README.md, QUICKSTART.md) - Beispiel-Beitrag mit vollständigem Frontmatter --- QUICKSTART.md | 194 ++++++++++++++++++------- README.md | 225 +++++++++++++++++++---------- content/beispiel-beitrag.md | 112 ++++++++++++--- markdown_parser.py | 226 +++++++++++++++++++++++++++++ posts.yaml.new | 28 ++++ workflow.py | 275 +++++++++++++++++++++++++++++++----- 6 files changed, 880 insertions(+), 180 deletions(-) create mode 100644 markdown_parser.py create mode 100644 posts.yaml.new diff --git a/QUICKSTART.md b/QUICKSTART.md index 316b478..8e058cb 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,5 +1,51 @@ # Schnellstart-Anleitung +## Überblick + +Das System extrahiert **automatisch alle Metadaten aus dem YAML-Frontmatter** Ihrer Markdown-Dateien: +- **name** → WordPress-Titel +- **description** oder **summary** → Excerpt +- **image** → Beitragsbild +- **tags** → WordPress-Tags +- **categories** → WordPress-Kategorien +- **author** → WordPress-Autor + +Sie müssen nur noch die **URL zur Markdown-Datei** angeben! + +## Drei Verwendungsmodi + +### 1. Einzelne URL (Am einfachsten!) + +```bash +source .venv/bin/activate +python workflow.py "https://example.com/artikel.md" +``` + +### 2. Mehrere URLs aus YAML-Datei + +Erstellen Sie `posts.yaml`: +```yaml +posts: + - url: "https://example.com/artikel1.md" + - url: "https://example.com/artikel2.md" + - file: "content/lokaler-artikel.md" +``` + +Dann ausführen: +```bash +source .venv/bin/activate +python workflow.py posts.yaml +``` + +### 3. Ganzes Repository (Forgejo/Gitea) + +```bash +source .venv/bin/activate +python workflow.py --repo "https://codeberg.org/user/repo" main +``` + +## Schnellstart-Schritte + ## 1. Virtuelle Umgebung aktivieren Aktivieren Sie zuerst die Python-Umgebung: @@ -28,64 +74,102 @@ WORDPRESS_USERNAME=IHR_USERNAME_HIER # ← Hier eintragen! WORDPRESS_APP_PASSWORD=UIVI 4Tdy oojL 9iZG g3X2 iAn5 ``` -## 4. posts.yaml anpassen +## 4. posts.yaml anpassen (Optional - nur für Batch-Verarbeitung) -Bearbeiten Sie `posts.yaml` und fügen Sie Ihre Beiträge hinzu: +Für **einzelne URLs** brauchen Sie keine YAML-Datei! + +Für **mehrere Beiträge** erstellen Sie `posts.yaml`: ```yaml posts: - - title: "Ihr Beitragstitel" - markdown_url: "https://ihre-url.de/artikel.md" - # ODER für lokale Dateien: - # markdown_file: "content/ihr-artikel.md" - status: "draft" - categories: - - "Ihre Kategorie" - tags: - - "Ihr Tag" + - url: "https://ihre-url.de/artikel.md" + - file: "content/lokaler-artikel.md" + +settings: + default_status: "draft" ``` +**Das war's!** Alle Metadaten (Titel, Tags, Kategorien, Bild) kommen aus dem Frontmatter der Markdown-Dateien. + ## 5. Workflow ausführen Stellen Sie sicher, dass die virtuelle Umgebung aktiviert ist (siehe Schritt 1), dann: +### Einzelne URL (empfohlen für Tests): ```bash -python workflow.py +python workflow.py "https://example.com/artikel.md" +``` + +### Mehrere URLs aus YAML: +```bash +python workflow.py posts.yaml +``` + +### Ganzes Forgejo-Repository: +```bash +python workflow.py --repo "https://codeberg.org/user/repo" main ``` ## Testen mit dem Beispiel-Beitrag -Ein Test-Beitrag ist bereits in `content/beispiel-beitrag.md` vorhanden. +Ein Test-Beitrag mit vollständigem Frontmatter ist in `content/beispiel-beitrag.md`. -Um diesen zu verwenden, passen Sie `posts.yaml` an: - -```yaml -posts: - - title: "Test: Beispiel-Beitrag" - markdown_file: "content/beispiel-beitrag.md" - status: "draft" - categories: - - "Test" +**Direkter Test:** +```bash +python workflow.py "content/beispiel-beitrag.md" ``` -Dann führen Sie aus: +**Oder über YAML:** +```yaml +posts: + - file: "content/beispiel-beitrag.md" +``` ```bash -python workflow.py +python workflow.py posts.yaml ``` ## Was passiert beim Ausführen? -1. ✅ System liest die `posts.yaml` -2. ✅ Für jeden Beitrag: - - Lädt Markdown-Inhalt (von URL oder lokal) - - Konvertiert Markdown zu HTML - - Prüft ob Beitrag bereits existiert (nach Titel) - - Erstellt fehlende Kategorien/Tags - - Lädt Beitragsbilder hoch (falls vorhanden) - - Erstellt den WordPress-Beitrag +1. ✅ System lädt die Markdown-Datei (von URL oder lokal) +2. ✅ **Extrahiert automatisch Metadaten aus dem YAML-Frontmatter:** + - `name` → Titel + - `description`/`summary` → Excerpt + - `image` → Beitragsbild + - `tags` → WordPress-Tags + - `categories` → WordPress-Kategorien (falls vorhanden) + - `author` → Autor +3. ✅ Konvertiert Markdown zu HTML +4. ✅ Prüft ob Beitrag bereits existiert (nach Titel) +5. ✅ Erstellt fehlende Kategorien/Tags +6. ✅ Lädt Beitragsbilder hoch (falls vorhanden) +7. ✅ Erstellt den WordPress-Beitrag -3. ✅ Zeigt Zusammenfassung an +## Frontmatter-Beispiel + +Ihre Markdown-Dateien sollten so aussehen: + +```markdown +--- +name: "Mein Artikel-Titel" +description: "Eine kurze Zusammenfassung des Artikels" +image: "https://example.com/bild.jpg" +tags: + - WordPress + - Tutorial + - Open Source +categories: + - Tutorials +author: + - Max Mustermann +--- + +# Artikel-Inhalt + +Hier beginnt der eigentliche Inhalt... +``` + +Das System versteht auch Schema.org-Metadaten (wie im `beispiel-beitrag.md`)! ## Wichtige Hinweise @@ -97,34 +181,38 @@ python workflow.py ## Beispiel-Ausgabe ``` -Lade Konfiguration aus: posts.yaml +Direkt-Modus: Verarbeite URL: https://example.com/artikel.md Verbinde mit WordPress: https://news.rpi-virtuell.de -Verarbeite 1 Beitrag/Beiträge... +============================================================ +Verarbeite Markdown von URL: https://example.com/artikel.md +============================================================ +Titel: Die Kraft der Gemeinschaft: Prozesse statt Strukturen +Beitrag 'Die Kraft der Gemeinschaft' erstellt (ID: 123, Status: draft) -============================================================ -Verarbeite Beitrag: Test: Beispiel-Beitrag -============================================================ -Lese lokale Markdown-Datei: content/beispiel-beitrag.md -Beitrag 'Test: Beispiel-Beitrag' erstellt (ID: 123, Status: draft) - -============================================================ -ZUSAMMENFASSUNG -============================================================ -Erfolgreich: 1 -Fehler: 0 -Gesamt: 1 -============================================================ +✅ Erfolgreich: Beitrag erstellt (ID: 123) ``` ## Nächste Schritte -1. Testen Sie mit dem Beispiel-Beitrag -2. Passen Sie `posts.yaml` für Ihre echten Beiträge an -3. Führen Sie `python workflow.py` aus -4. Überprüfen Sie die Beiträge in WordPress -5. Bei Erfolg: Ändern Sie `status: "draft"` zu `status: "publish"` +1. **Testen Sie mit dem Beispiel-Beitrag:** + ```bash + python workflow.py "content/beispiel-beitrag.md" + ``` + +2. **Testen Sie mit Ihrer eigenen URL:** + ```bash + python workflow.py "https://ihre-url.de/artikel.md" + ``` + +3. **Für Batch-Verarbeitung:** Erstellen Sie `posts.yaml` mit mehreren URLs + +4. **Für Repository-Import:** Nutzen Sie `--repo` für Forgejo/Gitea + +5. **Überprüfen Sie die Beiträge in WordPress** + +6. **Bei Erfolg:** Ändern Sie `default_status: "publish"` in den Settings ## Hilfe diff --git a/README.md b/README.md index 952afe1..f2899fb 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,18 @@ Automatisierter Workflow zum Erstellen von WordPress-Beiträgen aus Markdown-Dateien über die WordPress REST-API. +**Neu:** Metadaten werden automatisch aus dem YAML-Frontmatter der Markdown-Dateien extrahiert! + ## Features +- ✅ **Automatische Metadaten-Extraktion**: name, description, tags, image, author aus YAML-Frontmatter +- ✅ **Drei Verwendungsmodi**: Einzelne URL, YAML-Batch, Forgejo-Repository - ✅ **Duplikatsprüfung**: Verhindert das doppelte Erstellen von Beiträgen und Medien - ✅ **Markdown zu HTML**: Automatische Konvertierung von Markdown-Inhalten - ✅ **Medien-Upload**: Hochladen von Beitragsbildern mit Duplikatsprüfung - ✅ **Kategorien & Tags**: Automatische Erstellung fehlender Kategorien und Tags -- ✅ **Flexible Quellen**: Unterstützt Markdown-URLs und lokale Dateien -- ✅ **YAML-Konfiguration**: Einfache Verwaltung mehrerer Beiträge +- ✅ **Flexible Quellen**: Unterstützt Markdown-URLs, lokale Dateien und Forgejo-Repositories +- ✅ **Schema.org Support**: Versteht commonMetadata-Strukturen ## Voraussetzungen @@ -58,29 +62,31 @@ Automatisierter Workflow zum Erstellen von WordPress-Beiträgen aus Markdown-Dat ## Verwendung -### 1. YAML-Konfiguration erstellen +### Modus 1: Einzelne URL (Am einfachsten!) -Erstellen Sie eine `posts.yaml`-Datei mit Ihren Beiträgen: +Verarbeiten Sie eine einzelne Markdown-URL direkt: + +```bash +source .venv/bin/activate +python workflow.py "https://example.com/artikel.md" +``` + +Alle Metadaten (Titel, Tags, Kategorien, Bild) werden aus dem YAML-Frontmatter der Markdown-Datei extrahiert. + +### Modus 2: Mehrere URLs aus YAML-Datei + +Erstellen Sie eine `posts.yaml`-Datei: ```yaml +# Einfache URL-Liste - Metadaten kommen aus dem Frontmatter! posts: - - title: "Mein erster Beitrag" - markdown_url: "https://raw.githubusercontent.com/user/repo/main/post.md" - status: "draft" # draft, publish, pending, private - categories: - - "News" - - "Tutorials" - tags: - - "WordPress" - - "API" - featured_image: "images/header.jpg" - excerpt: "Eine kurze Zusammenfassung" - - - title: "Lokaler Beitrag" - markdown_file: "content/local-post.md" - status: "publish" - categories: - - "Updates" + - url: "https://example.com/artikel1.md" + - url: "https://example.com/artikel2.md" + - file: "content/lokaler-artikel.md" + + # Optional: Metadaten überschreiben + - url: "https://example.com/artikel3.md" + status: "publish" # Überschreibt Status aus Frontmatter settings: default_status: "draft" @@ -88,26 +94,84 @@ settings: skip_duplicate_media: true ``` -### 2. Workflow ausführen - -Aktivieren Sie zuerst die virtuelle Umgebung: +Dann ausführen: ```bash source .venv/bin/activate -``` - -Dann führen Sie das Workflow-Script aus: - -```bash python workflow.py posts.yaml ``` -Oder ohne Angabe der Datei (verwendet `posts.yaml` als Standard): +### Modus 3: Ganzes Forgejo/Gitea-Repository + +Verarbeiten Sie alle Markdown-Dateien aus einem Repository: ```bash -python workflow.py +source .venv/bin/activate +python workflow.py --repo "https://codeberg.org/user/repo" main ``` +Dies lädt automatisch alle `.md`-Dateien aus dem Repository und erstellt WordPress-Beiträge. + +## Markdown-Frontmatter Format + +Ihre Markdown-Dateien sollten YAML-Frontmatter enthalten: + +```markdown +--- +name: "Artikel-Titel" +description: "Kurze Zusammenfassung für WordPress-Excerpt" +image: "https://example.com/bild.jpg" +tags: + - WordPress + - Tutorial + - Open Source +categories: + - Tutorials +author: + - Max Mustermann +--- + +# Artikel-Inhalt + +Hier beginnt der eigentliche Markdown-Inhalt... +``` + +### Unterstützte Frontmatter-Felder + +Das System extrahiert automatisch: + +- **Titel**: `name` oder `title` +- **Excerpt**: `description` oder `summary` +- **Beitragsbild**: `image` oder `cover.image` +- **Tags**: `tags` (Liste oder kommagetrennt) +- **Kategorien**: `categories` (Liste oder kommagetrennt) +- **Autor**: `author` (String oder Liste) +- **Status**: `status` oder aus `creativeWorkStatus` +- **Datum**: `date` oder `datePublished` + +### Schema.org Support + +Das System versteht auch Schema.org-Metadaten: + +```yaml +--- +'@context': https://schema.org/ +type: LearningResource +name: "Artikel-Titel" +description: "Beschreibung" +image: "https://example.com/bild.jpg" +creator: + - givenName: Max + familyName: Mustermann + type: Person +tags: + - Tag1 + - Tag2 +--- +``` + +Siehe `content/beispiel-beitrag.md` für ein vollständiges Beispiel. + ## Struktur ``` @@ -117,11 +181,13 @@ newsimport/ ├── .gitignore # Git-Ignorier-Liste ├── requirements.txt # Python-Abhängigkeiten ├── wordpress_api.py # WordPress REST-API Client +├── markdown_parser.py # YAML-Frontmatter Parser ├── workflow.py # Haupt-Workflow Script -├── posts.yaml # Beitrags-Konfiguration +├── posts.yaml # Beitrags-Konfiguration (optional) ├── README.md # Diese Datei +├── QUICKSTART.md # Schnellstart-Anleitung ├── content/ # Lokale Markdown-Dateien (optional) -│ └── *.md +│ └── beispiel-beitrag.md └── images/ # Lokale Bilder (optional) └── *.jpg/png ``` @@ -163,33 +229,36 @@ existing_post_id = wp.check_post_exists("Titel") existing_media_id = wp.check_media_exists("bild.jpg") ``` -## YAML-Konfiguration +## YAML-Konfiguration (posts.yaml) -### Beitrags-Felder +### Vereinfachte Struktur -- `title` (erforderlich): Titel des Beitrags -- `markdown_url`: URL zur Markdown-Datei -- `markdown_file`: Pfad zu lokaler Markdown-Datei -- `content`: Direkter Markdown-Inhalt -- `status`: `draft`, `publish`, `pending`, `private` -- `categories`: Liste von Kategorie-Namen -- `tags`: Liste von Tag-Namen -- `featured_image`: Pfad oder URL zum Beitragsbild -- `excerpt`: Kurze Zusammenfassung -- `author`: Autor-Username +Metadaten werden automatisch aus dem Frontmatter extrahiert: + +```yaml +posts: + - url: "https://example.com/artikel.md" # URL zur Markdown-Datei + - file: "content/artikel.md" # Oder lokale Datei + + # Optional: Metadaten überschreiben + - url: "https://example.com/artikel2.md" + status: "publish" # Überschreibt Frontmatter + categories: # Ergänzt Frontmatter-Kategorien + - "Extra-Kategorie" +``` ### Globale Einstellungen ```yaml settings: - default_status: "draft" # Standard-Status für Beiträge - default_author: "admin" # Standard-Autor + default_status: "draft" # Fallback wenn nicht im Frontmatter + default_author: "admin" # Fallback wenn nicht im Frontmatter skip_duplicates: true # Bestehende Beiträge überspringen skip_duplicate_media: true # Bestehende Medien überspringen markdown_extensions: # Markdown-Erweiterungen - - tables - - fenced_code - - footnotes + - extra + - codehilite + - toc ``` ## Duplikatsprüfung @@ -202,41 +271,45 @@ Vor dem Upload wird geprüft, ob eine Datei mit dem gleichen Namen bereits exist ## Beispiele -### Beispiel 1: Einfacher Beitrag von URL +### Beispiel 1: Einzelne URL direkt -```yaml -posts: - - title: "News Update" - markdown_url: "https://example.com/news.md" - status: "publish" +```bash +python workflow.py "https://example.com/artikel.md" ``` -### Beispiel 2: Beitrag mit Kategorien, Tags und Bild +### Beispiel 2: Lokale Datei -```yaml -posts: - - title: "Tutorial: WordPress REST-API" - markdown_url: "https://example.com/tutorial.md" - status: "draft" - categories: - - "Tutorials" - - "WordPress" - tags: - - "REST-API" - - "Entwicklung" - - "PHP" - featured_image: "https://example.com/images/header.jpg" - excerpt: "Lernen Sie die WordPress REST-API kennen" +```bash +python workflow.py "content/beispiel-beitrag.md" ``` -### Beispiel 3: Lokale Dateien +### Beispiel 3: Mehrere URLs aus YAML ```yaml posts: - - title: "Lokaler Inhalt" - markdown_file: "content/article.md" - status: "publish" - featured_image: "images/local-image.jpg" + - url: "https://example.com/artikel1.md" + - url: "https://example.com/artikel2.md" + - file: "content/lokaler-artikel.md" +``` + +```bash +python workflow.py posts.yaml +``` + +### Beispiel 4: Forgejo-Repository + +```bash +python workflow.py --repo "https://codeberg.org/user/repo" main +``` + +### Beispiel 5: Metadaten überschreiben + +```yaml +posts: + - url: "https://example.com/artikel.md" + status: "publish" # Überschreibt Status aus Frontmatter + categories: # Ergänzt Kategorien aus Frontmatter + - "Extra-Kategorie" ``` ## Fehlerbehebung diff --git a/content/beispiel-beitrag.md b/content/beispiel-beitrag.md index 1d21466..6656f5c 100644 --- a/content/beispiel-beitrag.md +++ b/content/beispiel-beitrag.md @@ -1,25 +1,105 @@ -# Beispiel-Beitrag +--- -Dies ist ein Beispiel für einen lokalen Markdown-Beitrag. +#commonMetadata: +'@context': https://schema.org/ +creativeWorkStatus: Published +type: LearningResource +name: "Die Kraft der Gemeinschaft: Wahre Stärke liegt nicht in Strukturen, sondern in Prozessen" +description: >- + Im FOERBICO-Projekt zeigen wir: Nicht starre Strukturen machen Systeme dauerhaft + robust, sondern die Kontinuität und Anpassungsfähigkeit ihrer Prozesse. + Am Ise-Schrein und Open-Source-Prinzipien wird deutlich, wie Bildungsinfrastrukturen + gemeinschaftsgetragen, erneuerbar und offen gestaltet werden können – jenseits + geschlossener Plattformen hin zu atmenden Protokoll-Ökosystemen (z. B. Nostr). +license: https://creativecommons.org/licenses/by/4.0/deed.de +id: https://oer.community/die-kraft-der-gemeinschaft +creator: + - givenName: Jörg + familyName: Lohrer + id: https://orcid.org/0000-0002-9282-0406 + type: Person + affiliation: + name: Comenius-Institut + id: https://ror.org/025e8aw85 + type: Organization +inLanguage: + - de +about: + - https://w3id.org/kim/hochschulfaechersystematik/n052 + - https://w3id.org/kim/hochschulfaechersystematik/n079 + - https://w3id.org/kim/hochschulfaechersystematik/n544 +image: https://oer.community/die-kraft-der-gemeinschaft/nosTr-schrein.jpg +learningResourceType: + - https://w3id.org/kim/hcrt/text + - https://w3id.org/kim/hcrt/web_page +educationalLevel: + - https://w3id.org/kim/educationalLevel/level_A +datePublished: '2025-09-02' -## Einführung +#staticSiteGenerator: +author: + - Jörg Lohrer +title: 'Die Kraft der Gemeinschaft: Prozesse statt Strukturen' +cover: + relative: true + image: nosTr-schrein.jpg + caption: "Symbolbild: Der Ise-Schrein als Metapher für erneuerbare, gemeinschaftsgetragene Bildungsinfrastruktur." + alt: "Darstellung eines Schreins als Sinnbild zyklischer Erneuerung; übertragen auf offene Bildungsinfrastrukturen (z. B. Nostr)." + hiddenInSingle: true +summary: | + Warum Prozesse wichtiger sind als Strukturen: Was der Ise-Schrein und Open Source für eine resiliente, gemeinschaftsgetragene Bildungsinfrastruktur lehren. +url: die-kraft-der-gemeinschaft +tags: + - Open Educational Resources (OER) + - Open Educational Practices (OEP) + - Community + - FOERBICO + - Nostr + - Bildungsinfrastruktur -Dieser Beitrag demonstriert die Verwendung von lokalem Markdown-Content für WordPress-Beiträge. +--- -## Features +Zur Entwicklung unseres Community-Hubs untersuchen wir im FOERBICO-Projekt, wie langfristig erfolgreiche Kooperationsmodelle gelingen können. Eine wichtige Erkenntnis, die wir bisher gewinnen konnten: Die Robustheit eines Systems hängt weniger von seinen organisatorischen oder technischen Strukturen ab, sondern vor allem von der Kontinuität und Anpassungsfähigkeit seiner zugrunde liegenden Prozesse. Wenn wir mit einer Hub-Entwicklung die Bildungscommunities dabei unterstützen wollen, dass ihre Prozesse der OEP (Open Educational Practice) "[alles tragen, allem standhalten und niemals zu Fall kommen](https://offene-bibel.de/wiki/1_Korinther_13#l7)", brauchen wir eine Technik, die die zyklischen Erneuerungsprozesse dieser Communities nachhaltig unterstützt. Lasst uns einen Blick über den Tellerrand wagen und uns Inspiration aus jahrtausendealten Traditionen und Open-Source-Prinzipien schöpfen: -- **Markdown-Formatierung**: Vollständige Markdown-Unterstützung -- **Code-Blöcke**: Syntax-Highlighting für verschiedene Sprachen -- **Listen**: Geordnet und ungeordnet -- **Links und Bilder**: Vollständige Unterstützung +## Prozess statt Bauwerk – Die Kraft der Gemeinschaft am Beispiel des Ise-Schreins -## Code-Beispiel +Stell dir vor, du würdest alle 20 Jahre dein Haus abreißen und identisch wieder aufbauen. Verrückt? In Japan passiert genau das seit 1.300 Jahren mit dem Großen Schrein von Ise, dem heiligsten Ort des Landes. Das Geheimnis seiner Beständigkeit liegt also nicht im Bauwerk selbst, sondern im gemeinsamen Ritual seiner Erneuerung. -```python -def hello_world(): - print("Hello, WordPress!") -``` +Nicht das solide Gebäude selbst stiftet hier die Gemeinschaft, sondern die Zuverlässigkeit ihres kontinuierlichen Bauprozesses. Die Manifestation und Struktur des Schreins unterliegt also einem steten Wandel, während die Qualität und Verlässlichkeit im Prozess seiner Erneuerung durch die ritualisierten Abläufe über Generationen hinweg erhalten bleibt. -## Zusammenfassung +## Open Source: Liebe als erneuerbares Baumaterial -Mit diesem System können Sie einfach Markdown-Inhalte in WordPress-Beiträge umwandeln. +Clay Shirky beschreibt Open-Source-Projekte wie das Betriebssysten Linux als moderne Entsprechung zum Ise-Schrein: Ihre Beständigkeit beruhe nicht auf kommerzieller Unterstützung, sondern resultiere aus einem "Akt der Liebe" – sie sei getragen von Menschen, die sich umeinander kümmerten und gemeinsam etwas schaffen würden. + +Die entscheidende Frage für die Langlebigkeit eines Systems sei daher nicht die nach dem das Geschäftsmodell, sondern vielmehr: "Kümmern sich die Menschen, die es lieben, umeinander?" Dieser Indikator könnte sich als ein überlegener Prädiktor für nachhaltige Kooperationserfolge und die Langlebigkeit eines Community-Hubs erweisen. + +## Unsere digitalen Kathedralen der Bildung +Schauen wir auf unsere Bildungslandschaft, sehen wir oft das Gegenteil: abgeschlossene Plattformen und getrennte Datensilos. Wir bauen digitale Festungen statt lebendige Gemeinschaften. + +Anstatt Materialien gemeinsam zu ***v***erwenden, zu ***v***erarbeiten, zu ***v***ermischen, zu ***v***ervielfältigen und zu ***v***erbreiten, bleiben Bildungsmedien in Plattformen gefangen und ***v***erwahrt. Statt offener Kollaboration haben wir Insellösungen. + +## Eine Infrastruktur, die atmet + +Was wäre, wenn wir Bildungsinfrastruktur wie den Ise-Schrein denken würden? +Protokolle wie [Nostr](https://nostr.how/de/what-is-nostr) zeigen, wie das technisch möglich wird: dezentral, offen und von der Gemeinschaft getragen. + +Das Resultat wäre eine Infrastruktur, die nicht von einzelnen Plattformen, Institutionen oder "Internet-Gebäuden" abhängig ist, sondern von der kollektiven Fürsorge und dem Engagement der Community getragen werden kann – resilient, erneuerbar und offen für alle. + +## Mach mit beim Bauen! + +Die Geschichte des Ise-Schreins lehrt uns: Das beständigste Fundament sind die Menschen, die sich umeinander kümmern. Lasst uns gemeinsam ein lebendiges Ökosystem für die Bildung schaffen, das uns miteinander in Verbindung bringt! + +Hier kannst du mitmachen: +- Im Matrix [Space OERcommunity](https://matrix.to/#/#oercommunity:rpi-virtuell.de) Offene Räume für Austausch und Experimente + - vor allem [im Raum "edufeed"](https://matrix.to/#/#edufeed:rpi-virtuell.de), wo wir OER & NOSTR zusammendenken +- auf Nostr + - [hier eine Starthilfe zur Profilerstellung](https://nstart.me/de?an=Primal&am=light&aa=203a8f&asb=yes&s=npub1k85m3haymj3ggjknfrxm5kwtf5umaze4nyghnp29a80lcpmg2k2q54v05a) + - Hier ein paar Accounts z.B. von [Jörg](https://njump.me/npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9) oder [Steffen](https://njump.me/npub1r30l8j4vmppvq8w23umcyvd3vct4zmfpfkn4c7h2h057rmlfcrmq9xt9ma) +- GitHub [Edufeed](https://github.com/edufeed-org): Wo wir gemeinsam an der Zukunft bauen + +![](nosTr-schrein.jpg) + +**Inspirationen:** +- [Clay Shirky: Love, Internet Style](https://www.youtube.com/watch?v=Xe1TZaElTAs) +- [Steffen Rörtgen: Just calling it Open is not enough](https://habla.news/u/laoc42@getalby.com/h-k72fOoZmf_SOC3cUpqc) +- [Ise-Schrein – Japanliebe](https://japanliebe.de/alltaegliches/ise-jingu-schrein-neubau-alle-20-jahre/) \ No newline at end of file diff --git a/markdown_parser.py b/markdown_parser.py new file mode 100644 index 0000000..6c4ef1b --- /dev/null +++ b/markdown_parser.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +Markdown Parser mit YAML-Frontmatter Unterstützung +Extrahiert Metadaten aus Markdown-Dateien für WordPress-Import +""" + +import re +import yaml +from typing import Dict, Any, Optional, List + + +def extract_frontmatter(markdown_content: str) -> tuple[Optional[Dict[str, Any]], str]: + """ + Extrahiert YAML-Frontmatter und Markdown-Inhalt + + Args: + markdown_content: Vollständiger Markdown-Text + + Returns: + Tuple von (frontmatter_dict, markdown_body) + """ + # Regex für YAML-Frontmatter (zwischen --- Markern) + frontmatter_pattern = r'^---\s*\n(.*?)\n---\s*\n(.*)$' + match = re.match(frontmatter_pattern, markdown_content, re.DOTALL) + + if not match: + # Kein Frontmatter gefunden + return None, markdown_content + + frontmatter_text = match.group(1) + markdown_body = match.group(2) + + # YAML parsen + try: + frontmatter_data = yaml.safe_load(frontmatter_text) + return frontmatter_data, markdown_body + except yaml.YAMLError as e: + print(f"Warnung: Fehler beim Parsen des YAML-Frontmatters: {e}") + return None, markdown_content + + +def extract_wordpress_metadata(frontmatter: Dict[str, Any], + default_author: str = "admin") -> Dict[str, Any]: + """ + Extrahiert WordPress-relevante Metadaten aus Frontmatter + + Args: + frontmatter: Geparste Frontmatter-Daten + default_author: Fallback-Autor + + Returns: + Dictionary mit WordPress-Metadaten + """ + metadata = {} + + # Titel extrahieren (verschiedene Felder möglich) + # Priorität: title > name > (aus commonMetadata) + if 'title' in frontmatter: + metadata['title'] = frontmatter['title'] + elif 'name' in frontmatter: + metadata['title'] = frontmatter['name'] + elif isinstance(frontmatter.get('#commonMetadata'), dict): + common = frontmatter['#commonMetadata'] + metadata['title'] = common.get('name', '') + + # Beschreibung/Excerpt extrahieren + # Priorität: summary > description > (aus commonMetadata) + if 'summary' in frontmatter: + metadata['excerpt'] = frontmatter['summary'] + elif 'description' in frontmatter: + metadata['excerpt'] = frontmatter['description'] + elif isinstance(frontmatter.get('#commonMetadata'), dict): + common = frontmatter['#commonMetadata'] + metadata['excerpt'] = common.get('description', '') + + # Bild extrahieren + # Priorität: image > cover.image > (aus commonMetadata) + if 'image' in frontmatter: + metadata['featured_image'] = frontmatter['image'] + elif isinstance(frontmatter.get('cover'), dict): + cover_image = frontmatter['cover'].get('image', '') + if cover_image: + metadata['featured_image'] = cover_image + elif isinstance(frontmatter.get('#commonMetadata'), dict): + common = frontmatter['#commonMetadata'] + if 'image' in common: + metadata['featured_image'] = common['image'] + + # Tags extrahieren + if 'tags' in frontmatter: + tags = frontmatter['tags'] + if isinstance(tags, list): + metadata['tags'] = tags + elif isinstance(tags, str): + metadata['tags'] = [t.strip() for t in tags.split(',')] + + # Kategorien extrahieren (falls vorhanden) + if 'categories' in frontmatter: + categories = frontmatter['categories'] + if isinstance(categories, list): + metadata['categories'] = categories + elif isinstance(categories, str): + metadata['categories'] = [c.strip() for c in categories.split(',')] + + # Autor extrahieren + if 'author' in frontmatter: + author = frontmatter['author'] + if isinstance(author, list) and len(author) > 0: + metadata['author'] = author[0] + elif isinstance(author, str): + metadata['author'] = author + else: + metadata['author'] = default_author + elif isinstance(frontmatter.get('#staticSiteGenerator'), dict): + static_gen = frontmatter['#staticSiteGenerator'] + if 'author' in static_gen: + author = static_gen['author'] + if isinstance(author, list) and len(author) > 0: + metadata['author'] = author[0] + elif isinstance(author, str): + metadata['author'] = author + elif isinstance(frontmatter.get('#commonMetadata'), dict): + common = frontmatter['#commonMetadata'] + if 'creator' in common: + creator = common['creator'] + if isinstance(creator, list) and len(creator) > 0: + first_creator = creator[0] + if isinstance(first_creator, dict): + given = first_creator.get('givenName', '') + family = first_creator.get('familyName', '') + metadata['author'] = f"{given} {family}".strip() + + # Fallback für Autor + if 'author' not in metadata: + metadata['author'] = default_author + + # Status extrahieren (falls vorhanden) + if 'status' in frontmatter: + metadata['status'] = frontmatter['status'] + elif isinstance(frontmatter.get('#commonMetadata'), dict): + common = frontmatter['#commonMetadata'] + work_status = common.get('creativeWorkStatus', '').lower() + if work_status == 'published': + metadata['status'] = 'publish' + elif work_status == 'draft': + metadata['status'] = 'draft' + + # Datum extrahieren (falls vorhanden) + if 'date' in frontmatter: + metadata['date'] = frontmatter['date'] + elif 'datePublished' in frontmatter: + metadata['date'] = frontmatter['datePublished'] + elif isinstance(frontmatter.get('#commonMetadata'), dict): + common = frontmatter['#commonMetadata'] + if 'datePublished' in common: + metadata['date'] = common['datePublished'] + + return metadata + + +def parse_markdown_with_metadata(markdown_content: str, + default_author: str = "admin") -> Dict[str, Any]: + """ + Parst Markdown-Datei und extrahiert alle WordPress-relevanten Daten + + Args: + markdown_content: Vollständiger Markdown-Text + default_author: Fallback-Autor + + Returns: + Dictionary mit 'metadata' und 'content' (Markdown-Body) + """ + frontmatter, markdown_body = extract_frontmatter(markdown_content) + + result = { + 'content': markdown_body, + 'metadata': {} + } + + if frontmatter: + result['metadata'] = extract_wordpress_metadata(frontmatter, default_author) + + return result + + +def get_base_url(url: str) -> str: + """ + Extrahiert die Basis-URL aus einer vollständigen URL + Nützlich für relative Bild-Pfade + + Args: + url: Vollständige URL + + Returns: + Basis-URL (z.B. https://example.com/path/) + """ + parts = url.rsplit('/', 1) + if len(parts) == 2: + return parts[0] + '/' + return url + + +def resolve_relative_image_url(image_path: str, base_url: str) -> str: + """ + Löst relative Bild-URLs auf + + Args: + image_path: Bild-Pfad (relativ oder absolut) + base_url: Basis-URL der Markdown-Datei + + Returns: + Absolute URL zum Bild + """ + # Wenn bereits absolute URL, zurückgeben + if image_path.startswith('http://') or image_path.startswith('https://'): + return image_path + + # Relative URL auflösen + if image_path.startswith('/'): + # Absoluter Pfad auf dem Server + from urllib.parse import urlparse + parsed = urlparse(base_url) + return f"{parsed.scheme}://{parsed.netloc}{image_path}" + else: + # Relativer Pfad + return base_url + image_path diff --git a/posts.yaml.new b/posts.yaml.new new file mode 100644 index 0000000..0db65cf --- /dev/null +++ b/posts.yaml.new @@ -0,0 +1,28 @@ +# WordPress Import Konfiguration +# Metadaten werden aus dem YAML-Frontmatter der Markdown-Dateien extrahiert + +# Einfache URL-Liste (Metadaten aus Frontmatter) +posts: + - url: "https://example.com/artikel1.md" + - url: "https://example.com/artikel2.md" + - url: "https://raw.githubusercontent.com/user/repo/main/docs/post.md" + + # Oder lokale Dateien + - file: "content/beispiel-beitrag.md" + + # Optional: Metadaten überschreiben + - url: "https://example.com/artikel3.md" + status: "publish" # Überschreibt Status aus Frontmatter + categories: # Überschreibt Kategorien aus Frontmatter + - "Zusätzliche Kategorie" + +# Globale Einstellungen +settings: + default_status: "draft" # Fallback wenn nicht im Frontmatter + default_author: "admin" # Fallback wenn nicht im Frontmatter + skip_duplicates: true # Bestehende Beiträge überspringen + skip_duplicate_media: true # Bestehende Medien überspringen + markdown_extensions: # Markdown-Erweiterungen + - extra + - codehilite + - toc diff --git a/workflow.py b/workflow.py index 44efc95..36bedf6 100644 --- a/workflow.py +++ b/workflow.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """ WordPress Import Workflow -Liest Markdown-Dateien aus URLs oder lokalen Dateien und erstellt WordPress-Beiträge -basierend auf einer YAML-Konfigurationsdatei. +Liest Markdown-Dateien aus URLs oder lokalen Dateien und erstellt WordPress-Beiträge. +Metadaten werden aus dem YAML-Frontmatter der Markdown-Dateien extrahiert. """ import os @@ -14,6 +14,11 @@ from pathlib import Path from dotenv import load_dotenv from typing import Dict, Any, List, Optional from wordpress_api import WordPressAPI +from markdown_parser import ( + parse_markdown_with_metadata, + get_base_url, + resolve_relative_image_url +) # Lade Umgebungsvariablen load_dotenv() @@ -122,75 +127,110 @@ def process_featured_image(wp_api: WordPressAPI, image_path: str, def process_post(wp_api: WordPressAPI, post_config: Dict[str, Any], global_settings: Dict[str, Any]) -> Optional[int]: """ - Verarbeitet einen einzelnen Beitrag aus der Konfiguration + Verarbeitet einen einzelnen Beitrag aus der Konfiguration. + Metadaten werden aus dem YAML-Frontmatter extrahiert. Args: wp_api: WordPress API Client - post_config: Beitrags-Konfiguration + post_config: Beitrags-Konfiguration (kann nur URL enthalten) global_settings: Globale Einstellungen Returns: Post-ID oder None """ - title = post_config.get('title') - if not title: - print("Fehler: Titel fehlt in der Beitragskonfiguration") + # URL oder Datei ermitteln + source_url = post_config.get('url') or post_config.get('markdown_url') + source_file = post_config.get('file') or post_config.get('markdown_file') + + if not source_url and not source_file: + print("Fehler: Keine URL oder Datei in der Beitragskonfiguration") return None print(f"\n{'='*60}") - print(f"Verarbeite Beitrag: {title}") + if source_url: + print(f"Verarbeite Markdown von URL: {source_url}") + else: + print(f"Verarbeite lokale Markdown-Datei: {source_file}") print(f"{'='*60}") # Markdown-Inhalt abrufen markdown_content = None - if 'markdown_url' in post_config: - print(f"Lade Markdown von URL: {post_config['markdown_url']}") - markdown_content = download_markdown(post_config['markdown_url']) - elif 'markdown_file' in post_config: - print(f"Lese lokale Markdown-Datei: {post_config['markdown_file']}") - markdown_content = read_local_markdown(post_config['markdown_file']) - elif 'content' in post_config: - markdown_content = post_config['content'] + base_url = None + + if source_url: + markdown_content = download_markdown(source_url) + base_url = get_base_url(source_url) + elif source_file: + markdown_content = read_local_markdown(source_file) + # Bei lokalen Dateien nehmen wir das Verzeichnis als Basis + base_url = os.path.dirname(os.path.abspath(source_file)) + '/' if not markdown_content: - print(f"Fehler: Kein Inhalt für Beitrag '{title}'") + print(f"Fehler: Konnte Markdown-Inhalt nicht laden") return None + # Markdown parsen und Metadaten extrahieren + default_author = global_settings.get('default_author', 'admin') + parsed = parse_markdown_with_metadata(markdown_content, default_author) + + metadata = parsed['metadata'] + markdown_body = parsed['content'] + + # Titel prüfen + title = metadata.get('title') or post_config.get('title') + if not title: + print("Fehler: Kein Titel gefunden (weder im Frontmatter noch in der Konfiguration)") + return None + + print(f"Titel: {title}") + # Markdown zu HTML konvertieren extensions = global_settings.get('markdown_extensions', ['extra', 'codehilite', 'toc']) - html_content = markdown_to_html(markdown_content, extensions) + html_content = markdown_to_html(markdown_body, extensions) # Kategorien verarbeiten + # Priorität: Frontmatter > post_config > global_settings category_ids = [] - if 'categories' in post_config: - for cat_name in post_config['categories']: - cat_id = wp_api.get_or_create_category(cat_name) - if cat_id: - category_ids.append(cat_id) + categories_list = metadata.get('categories') or post_config.get('categories') or [] + + for cat_name in categories_list: + cat_id = wp_api.get_or_create_category(cat_name) + if cat_id: + category_ids.append(cat_id) # Tags verarbeiten tag_ids = [] - if 'tags' in post_config: - for tag_name in post_config['tags']: - tag_id = wp_api.get_or_create_tag(tag_name) - if tag_id: - tag_ids.append(tag_id) + tags_list = metadata.get('tags') or post_config.get('tags') or [] + + for tag_name in tags_list: + tag_id = wp_api.get_or_create_tag(tag_name) + if tag_id: + tag_ids.append(tag_id) # Beitragsbild verarbeiten featured_media_id = None - if 'featured_image' in post_config: + featured_image = metadata.get('featured_image') or post_config.get('featured_image') + + if featured_image: + # Relative URLs auflösen + if base_url and not featured_image.startswith('http'): + featured_image = resolve_relative_image_url(featured_image, base_url) + skip_duplicate_media = global_settings.get('skip_duplicate_media', True) featured_media_id = process_featured_image( wp_api, - post_config['featured_image'], + featured_image, check_duplicate=skip_duplicate_media ) # Status - status = post_config.get('status', global_settings.get('default_status', 'draft')) + status = metadata.get('status') or post_config.get('status') or global_settings.get('default_status', 'draft') # Excerpt - excerpt = post_config.get('excerpt', '') + excerpt = metadata.get('excerpt') or post_config.get('excerpt', '') + + # Autor + author_name = metadata.get('author') or post_config.get('author') or default_author # Beitrag erstellen skip_duplicates = global_settings.get('skip_duplicates', True) @@ -208,15 +248,180 @@ def process_post(wp_api: WordPressAPI, post_config: Dict[str, Any], return post_id +def fetch_forgejo_repo_markdown_files(repo_url: str, branch: str = 'main') -> List[str]: + """ + Holt alle Markdown-URLs aus einem Forgejo-Repository + + Args: + repo_url: URL zum Repository (z.B. https://codeberg.org/user/repo) + branch: Branch-Name (Standard: main) + + Returns: + Liste von URLs zu Markdown-Dateien + """ + # Forgejo/Gitea API endpoint + # Format: https://codeberg.org/api/v1/repos/{owner}/{repo}/git/trees/{branch}?recursive=true + + # URL parsen + parts = repo_url.rstrip('/').split('/') + if len(parts) < 2: + print(f"Fehler: Ungültige Repository-URL: {repo_url}") + return [] + + owner = parts[-2] + repo = parts[-1] + + # API-URL ermitteln + if 'codeberg.org' in repo_url: + api_base = 'https://codeberg.org/api/v1' + elif 'gitea' in repo_url or 'forgejo' in repo_url: + # Generischer Ansatz für selbst-gehostete Instanzen + base_parts = repo_url.split('/')[:3] + api_base = '/'.join(base_parts) + '/api/v1' + else: + print(f"Warnung: Unbekannte Forgejo-Instanz, versuche generischen API-Pfad") + base_parts = repo_url.split('/')[:3] + api_base = '/'.join(base_parts) + '/api/v1' + + api_url = f"{api_base}/repos/{owner}/{repo}/git/trees/{branch}?recursive=true" + + try: + response = requests.get(api_url, timeout=30) + response.raise_for_status() + data = response.json() + + markdown_files = [] + for item in data.get('tree', []): + if item['type'] == 'blob' and item['path'].endswith('.md'): + # Raw-URL konstruieren + raw_url = f"https://codeberg.org/{owner}/{repo}/raw/branch/{branch}/{item['path']}" + markdown_files.append(raw_url) + + return markdown_files + + except requests.exceptions.RequestException as e: + print(f"Fehler beim Abrufen der Repository-Dateien: {e}") + return [] + + def main(): """Hauptfunktion des Workflows""" - # Konfigurationsdatei laden - config_file = sys.argv[1] if len(sys.argv) > 1 else 'posts.yaml' + # Kommandozeilen-Argumente verarbeiten + if len(sys.argv) > 1: + arg = sys.argv[1] + + # Prüfe ob es eine direkte URL ist + if arg.startswith('http://') or arg.startswith('https://'): + # Direkter URL-Modus + print(f"Direkt-Modus: Verarbeite URL: {arg}") + + # WordPress-Credentials + wp_url = os.getenv('WORDPRESS_URL') + wp_username = os.getenv('WORDPRESS_USERNAME') + wp_password = os.getenv('WORDPRESS_APP_PASSWORD') + + if not all([wp_url, wp_username, wp_password]): + print("Fehler: WordPress-Credentials fehlen in .env-Datei") + sys.exit(1) + + wp_api = WordPressAPI(wp_url, wp_username, wp_password) + + # Erstelle minimale Konfiguration + post_config = {'url': arg} + global_settings = { + 'default_status': 'draft', + 'default_author': 'admin', + 'skip_duplicates': True, + 'skip_duplicate_media': True + } + + try: + post_id = process_post(wp_api, post_config, global_settings) + if post_id: + print(f"\n✅ Erfolgreich: Beitrag erstellt (ID: {post_id})") + else: + print(f"\n❌ Fehler beim Erstellen des Beitrags") + sys.exit(1) + except Exception as e: + print(f"Fehler: {e}") + sys.exit(1) + + return + + # Prüfe ob es eine Forgejo-Repo-URL ist + elif '--forgejo-repo' in sys.argv or '--repo' in sys.argv: + repo_index = sys.argv.index('--forgejo-repo') if '--forgejo-repo' in sys.argv else sys.argv.index('--repo') + if len(sys.argv) > repo_index + 1: + repo_url = sys.argv[repo_index + 1] + branch = sys.argv[repo_index + 2] if len(sys.argv) > repo_index + 2 else 'main' + + print(f"Forgejo-Modus: Verarbeite Repository: {repo_url}") + print(f"Branch: {branch}") + + markdown_urls = fetch_forgejo_repo_markdown_files(repo_url, branch) + + if not markdown_urls: + print("Keine Markdown-Dateien im Repository gefunden") + sys.exit(1) + + print(f"\nGefundene Markdown-Dateien: {len(markdown_urls)}") + + # WordPress-Credentials + wp_url = os.getenv('WORDPRESS_URL') + wp_username = os.getenv('WORDPRESS_USERNAME') + wp_password = os.getenv('WORDPRESS_APP_PASSWORD') + + if not all([wp_url, wp_username, wp_password]): + print("Fehler: WordPress-Credentials fehlen in .env-Datei") + sys.exit(1) + + wp_api = WordPressAPI(wp_url, wp_username, wp_password) + + global_settings = { + 'default_status': 'draft', + 'default_author': 'admin', + 'skip_duplicates': True, + 'skip_duplicate_media': True + } + + success_count = 0 + error_count = 0 + + for url in markdown_urls: + try: + post_config = {'url': url} + post_id = process_post(wp_api, post_config, global_settings) + if post_id: + success_count += 1 + else: + error_count += 1 + except Exception as e: + print(f"Fehler bei der Verarbeitung von {url}: {e}") + error_count += 1 + + print(f"\n{'='*60}") + print(f"ZUSAMMENFASSUNG") + print(f"{'='*60}") + print(f"Erfolgreich: {success_count}") + print(f"Fehler: {error_count}") + print(f"Gesamt: {len(markdown_urls)}") + print(f"{'='*60}\n") + + return + + # Sonst als Konfigurationsdatei behandeln + config_file = arg + else: + config_file = 'posts.yaml' + # Konfigurationsdatei-Modus if not os.path.exists(config_file): print(f"Fehler: Konfigurationsdatei '{config_file}' nicht gefunden") - print("Verwendung: python workflow.py [config.yaml]") + print("\nVerwendung:") + print(" python workflow.py [config.yaml] # YAML-Konfiguration") + print(" python workflow.py # Einzelne Markdown-URL") + print(" python workflow.py --repo [branch] # Forgejo-Repository") sys.exit(1) print(f"Lade Konfiguration aus: {config_file}")