Featured image

Automatisierter Buch-Feed mit Hugo und Bookrastinating.com


Weil ich viel lese und einen Überblick behalten will, was ich gelesen habe, bin ich durch verschiedene solcher Apps geeiert, wo man das vermerkt und teilt. Alle hatten ihre Vor- und Nachteile, die aber irgendwann enshittificated wurden. Am Ende bin ich nun mit meinem Büchern auf Bookrastinating.com gelandet, auch wenn dort nicht so viel diskutiert wird, wie ich es gerne haben will.

In diesem Beitrag zeige ich, wie ich meinen aktuellen Buch-Feed von Bookrastinating.com automatisch in meinen Hugo-Blog integriere – inklusive aller nötigen Skripte und Code-Beispiele. Denn Bookrastinating.com läuft auf BookWyrm, und das ist relativ gut beschrieben und offen (s. BookWyrm Handbuch).

Ziel

Die Bücher, die ich gerade lese oder gelesen habe, sollen automatisch auf einer eigenen Seite im Blog erscheinen. Dabei soll das nicht regelmässig erneuert werden, sondern nur, wenn ich was poste, sozusagen im Rahmen des Postens.

Mein Workflow im Überblick

  1. Python-Skript holt die Daten von Bookrastinating (aus den verschiedenen Feeds dort) und wandelt sie in JSON um
  2. das Skript trägt das aktuelle Datum ins Frontmatter der Seite ein, die meine Liste gelesener Bücher zeigen soll
  3. Hugo, wenn auf Github angestossen, wenn ich einen neuen Post gitte, liest die JSON-Daten aus /data/ und zeigt sie mithilfe eines Shortcodes als Stream an
  4. Alles läuft auch automatisiert per GitHub Actions (das Skript kann ich lokal laufen lassen, aber es wird auch auf Github angestossen, und automatisch bauen und deployen haben wir ja schon)

Ein Python-Skript zum Feed holen und Daten aufbereiten

Das Skript fetch_book_feed.py sammelt die letzten Feed-Items ein und das Datum, wandelt die Feeds in JSON um und legt das in eine Datei und trägt das Datum im Frontmatter der Unterseite “Was ich lese” ein. Das ist erforderlich, weil Hugo beim CI/CD-Build and Deploy nicht ins Web darf. Daher lassen wir davor eine Github-Action ausführen, die das Skript aufruft und die Daten in die Datei schreibt (denn so eine Action kann ins Internet).

import requests
import xml.etree.ElementTree as ET
import json
import re
import os
from pathlib import Path
from datetime import date

FEED_URL = "https://bookrastinating.com/user/gregorgross/rss"
OUT_PATH = os.path.join("data", "book_feed.json")

# hier strippen wir mehrfache Sonderzeichen im Feed wie z.B. /n
def clean_text(text):
    text = re.sub(r'[\r\n]+', '\n', text or '').strip()
    text = re.sub(r'(\n)+', '\n', text)
    return text

def fetch_and_convert():
    today = date.today().isoformat()
    # Frontmatter in reading.md aktualisieren
    md_path = Path("content/reading.md")
    if md_path.exists():
        lines = md_path.read_text(encoding="utf-8").splitlines()
        # Frontmatter suchen und ersetzen/ergänzen
        new_lines = []
        in_frontmatter = False
        date_set = False
        for line in lines:
            if line.strip() == "---":
                in_frontmatter = not in_frontmatter
                new_lines.append(line)
                continue
            if in_frontmatter and line.strip().startswith("date:"):
                new_lines.append(f"date: {today}")
                date_set = True
            else:
                new_lines.append(line)
        # Falls kein date-Feld vorhanden, ergänzen
        if in_frontmatter and not date_set:
            idx = 1
            while idx < len(new_lines) and new_lines[idx].strip() != "---":
                idx += 1
            new_lines.insert(idx, f"date: {today}")
        md_path.write_text("\n".join(new_lines), encoding="utf-8")
    resp = requests.get(FEED_URL)
    resp.raise_for_status()
    root = ET.fromstring(resp.content)
    items = []
    book_author_map = {}
    # hier suchen wir uns die wichtigen Infos über die Bücher aus dem RSS-Feed und müssen manchmal ein bisschen hin und her suchen, um alle Infos zu finden (denn Bookrastinating hat manchmal nicht alle Infos im Feed drin, wie z.B. Autor)
    for item in root.findall(".//item"):
        title = clean_text(item.findtext("title"))
        link = item.findtext("link")
        description = clean_text(item.findtext("description"))
        author = None
        book = None
        author_match = re.search(r'by ([^(]+)', title or '')
        if author_match:
            author = author_match.group(1).strip()
        book_match = re.search(r'<i>(.*?)</i>', description or '')
        if book_match:
            book = book_match.group(1)
        if title and title.lower().startswith('review of'):
            review_book_match = re.search(r'Review of "([^"]+)"', title)
            if review_book_match:
                book = review_book_match.group(1)
            if book and book in book_author_map:
                author = book_author_map[book]
        if book and author and (title and ('started reading' in title or 'finished reading' in title)):
            book_author_map[book] = author
        items.append({
            "title": title,
            "link": link,
            "book": book,
            "author": author,
            "description": description
        })
    items = items[:20]
    out = {
        "date": today,
        "books": items
    }
    os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
    with open(OUT_PATH, "w", encoding="utf-8") as f:
        json.dump(out, f, ensure_ascii=False, indent=2)

if __name__ == "__main__":
    fetch_and_convert()

Hugo-Shortcode: Anzeige als Stream

Das Shortcode-Template layouts/shortcodes/reading.html sorgt für die Darstellung. Wir verwenden einmal saheHTML auf die description, weil dort öfter <p>blabla</p>-Tags vorkommen. Am Ende findet ihr den CSS-Code für den Stream der Bücher:

{{ $feed := site.Data.book_feed }}
{{ $books := $feed.books }}
{{ if and $books (gt (len $books) 0) }}
  <div class="book-feed-stream">
    {{ range $books }}
      <div class="book-card">
        <div class="book-card-header">
          <a class="book-title" href="{{ .link }}">{{ .book }}</a>
          {{ with .author }}<span class="book-author">von {{ . }}</span>{{ end }}
        </div>
        <div class="book-card-description">{{ .description | safeHTML }}</div>
      </div>
    {{ end }}
  </div>
{{ else }}
  <div class="book-feed-error">Keine Einträge gefunden oder Fehler beim Einlesen der JSON-Datei.</div>
{{ end }}
<style>
  .book-feed-stream {
    display: flex;
    flex-direction: column;
    gap: 1.5em;
    max-width: 600px;
    margin: 2em auto;
  }
  .book-card {
    background: #f6fafd;
    color: #222;
    border-radius: 12px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
    padding: 1em 1.5em;
    border: 1px solid #e0e7ef;
    font-family: 'Inter', 'Segoe UI', Arial, sans-serif;
  }
  .book-card-header {
    display: flex;
    align-items: baseline;
    gap: 0.5em;
    margin-bottom: 0.5em;
  }
  .book-title {
    font-weight: bold;
    font-size: 1.1em;
    color: #1da1f2;
    text-decoration: none;
  }
  .book-title:hover {
    text-decoration: underline;
  }
  .book-author {
    color: #aaa;
    font-size: 0.95em;
  }
  .book-card-description p {
    margin: 0.5em 0;
  }
  .book-feed-error {
    color: #ff3860;
    text-align: center;
    margin: 2em 0;
  }
</style>

Automatisierung mit GitHub Actions

Wie gesagt, wenn ich das lokale Verzeichnis, z.B. mit einem neuen blogpost.md in /posts, gitte, wird das Blog automatisch auf Github gebaut und auf https://denkpass.de deployed. Dabei kann Hugo, dass in einem Docker-Container läuft, diesen aber nicht verlassen, also nicht nach meinem Buch-Feed schauen. Dafür brauche ich eine GitHub Action. Mit einem einfachen Workflow kann das Skript regelmäßig ausgeführt und die Seite automatisch gebaut werden. Das sieht dann so aus in Github und diese Action läuft nun sogar jeden Montag um 6:00 Uhr (also aktualisiert sich der Feed wöchentlich in meine “Was ich lese”-Unterseite) [1] Die Github Action lief nicht wegen fehlender Schreibrechte. Das haben wir zugefügt am Anfang, bei permissions:. Ausserdem läuft mein CI/CD über Netlify und lief doppelt beim Pushed durch mich, danach beim Pushen durch Github Action. Das habe ich abgestellt, s. Netlify Hook am Ende. Wichtig dabei: auch wenn das mit dem Feed-Fetchen nicht klappt, pushen (daher die || true). :

name: Buch-Feed aktualisieren
permissions:
  contents: write
on:
  schedule:
    - cron: '0 6 * * 1'
  push:
    branches:
      - master
jobs:
  update-feed:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Python-Feed holen
        run: |
          python3 -m pip install requests
          python3 fetch_book_feed.py
      - name: Änderungen committen und pushen
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          git config --global user.name "github-actions"
          git config --global user.email "actions@github.com"
          git add data/book_feed.json content/reading.md || true
          git commit -m "[Netlify-Trigger] Automatischer Buch-Feed-Update" || true
          git push origin HEAD:master || true
      - name: Trigger Netlify Build
        if: always()
        run: |
          curl -X POST -d {} https://api.netlify.com/build_hooks/<netlify-build-hook-id>

Fazit

Und das war’s. Hat mich auch nur ein paar Stunden gekostet, aber hat geklappt. Eine heavy dose of Vibe Coding in Visual Studio Code mit GPT4.1. [2] An der Stelle dachte ich, wir hätten fertig ge-Vibe-coded.

Später stellte sich heraus, dass die Unterseite /reading.md nun auch auf der Frontseite und in der Blogübersicht angezeigt wurde. Wir mussten sie in einer list.html-Datei in meinem Template (Whiteplain) ausschliessen:

    {{- $posts := where site.RegularPages "Section" "posts" }}
    {{- $pages := where $posts "File.Path" "!=" "content/reading.md" }}

Denn diese Datei wird ja nun regelmässig geupdated und das würde bedeuten, dass sie dann immer nach dem Update ganz oben auf Frontseite und Blogübersicht angezeigt würde.