Notion mit offizieller API und GitHub Actions nach GitHub sichern
Wer Notion schon einmal ernsthaft sichern wollte, ist wahrscheinlich auf eines von zwei Problemen gestoßen: Entweder basierte der Ablauf auf inoffiziellen Browser-Tokens, die irgendwann abliefen, oder der Export wirkte erfolgreich, bis auffiel, dass nur die oberste Seite erfasst wurde und Unterseiten, Datenbanken, Anhänge und Dateinamen weiterhin chaotisch waren.
Der Aufbau, den ich am Ende beibehalten habe, ist deutlich stabiler: die offizielle Notion API verwenden, eine saubere Internal Integration einrichten und GitHub Actions den Export nach Zeitplan ausführen lassen. Damit verschwinden drei typische Schwachstellen auf einmal: keine Cookie-basierte Authentifizierung mehr, keine Abhängigkeit von einem eingeschalteten lokalen Rechner und ein sauberer Ort, um Rekursion und Dateiumbenennung in den Export einzubauen.
Wenn du nach Dingen wie Notion backup to GitHub, Notion GitHub Actions backup oder export Notion to Markdown with the official API suchst, geht es in diesem Beitrag genau um dieses Problem.
Warum ich inoffizielle tokenbasierte Backups nicht mehr verwende
Inoffizielle Methoden können funktionieren, besonders für einen schnellen Proof of Concept. Die Schwierigkeiten kommen später.
Der Workflow sieht gut aus, bis eines dieser Dinge passiert:
- die Browser-Session läuft ab
- das Token wird ungültig
- eine andere IP oder Umgebung liefert plötzlich 401 oder 403
- der Export läuft noch, erfasst aber nicht den vollständigen Datenumfang
Die offizielle API stabilisiert zuerst die fragilste Schicht:
- Auth basiert auf einem Internal-Integration-Token statt auf einer ausgeliehenen Browser-Session
- Seitenzugriff ist explizit, sodass klar ist, warum eine Seite lesbar ist oder nicht
- GitHub Actions liefert Scheduler und Logs direkt mit
Selbst bauen muss man weiterhin den praktischen Teil: rekursives Traversieren, Behandlung von Anhängen und lesbare Ausgabe.
So sieht der fertige Backup-Flow aus
Die gesamte Kette lässt sich leichter verstehen, wenn man sie von oben nach unten betrachtet:
Notion-Seite auf oberster Ebene
↓
Autorisierung der Internal Integration
↓
Geplanter GitHub-Actions-Lauf
↓
Python-Skript mit notion-client + notion2md
↓
Rekursive Traversierung von Unterseiten und Unterdatenbanken
↓
Markdown-Export + Download von Anhängen
↓
ZIP-Extraktion
↓
Block-ID-Dateien auf echte Seitentitel umbenennen
↓
Alles zurück nach GitHub committen
GitHub Actions ist hier nur der Runner. Der eigentliche Mehrwert steckt in der Python-Schicht dazwischen, weil der Standard-Exportpfad noch kein vollständiges rekursives Archiv in der Form liefert, die man in der Praxis meistens möchte.
Zuerst die offizielle Autorisierung korrekt einrichten
Beginne in der Notion Developer Console und erstelle eine Internal Integration:
- Öffne Notion Integrations
- Erstelle eine neue Integration und wähle
Internal - Gib ihr mindestens
Read content - Kopiere das Token, das mit
secret_beginnt
Dieses Token allein reicht nicht. Notion-Berechtigungen sind explizit – was gut ist –, aber die Integration kann eine Seite erst lesen, nachdem sie mit dieser Seite verbunden wurde.
Für die oberste Seite, die gesichert werden soll:
- Seite in Notion öffnen
- auf das Menü
...klicken Add connectionsauswählen- die gerade erstellte Integration hinzufügen
Fehlt dieser Schritt, sieht das typische Fehlerbild so aus:
- Token scheint gültig
- Page ID scheint gültig
- API meldet trotzdem, dass der Block nicht gefunden wird
Zusätzlich brauchst du die ID der Root-Seite. In der Regel ist das die 32 Zeichen lange ID in der Seiten-URL, mit oder ohne Bindestriche.
Auf GitHub reichen zwei Secrets
Secrets würde ich nicht direkt in die Workflow-Datei schreiben. Sauberer ist es, sie als Repository Secrets zu speichern:
NOTION_INTEGRATION_TOKENNOTION_PAGE_ID
Dann erstellst du:
.github/workflows/backup.yml
Der äußere Workflow muss nicht kompliziert sein. Er soll lediglich:
- das Repository auschecken
- Python einrichten
notion-clientundnotion2mdinstallieren- das Export-Skript ausführen
- das Ergebnis committen, wenn sich Inhalte geändert haben
Eine minimale Variante sieht so aus:
name: "Notion Official API Backup"
on:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
backup:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
run: pip install notion2md notion-client
- name: Run export
env:
NOTION_TOKEN: ${{ secrets.NOTION_INTEGRATION_TOKEN }}
NOTION_PAGE_ID: ${{ secrets.NOTION_PAGE_ID }}
run: python export_script.py
- name: Commit changes
run: |
git config --local user.email "[email protected]"
git config --local user.name "GitHub Action Backup"
git add .
if git diff --staged --quiet; then
echo "No content changes"
else
git commit -m "Auto backup: $(date +'%Y-%m-%d %H:%M:%S')"
git push
fi
Damit ist die Automation bereits real. Der interessante Teil bleibt aber das Export-Skript.
Entscheidend ist der rekursive Export, nicht der Workflow-Wrapper
Das Standardproblem eines Exports über die offizielle API ist nicht, dass er komplett scheitert. Er hört nur zu früh auf.
Wer nur eine Seite exportiert, landet häufig bei:
- Root-Seite exportiert
- Unterseiten fehlen
- Unterdatenbanken werden zu separaten Problemen
- Dateinamen basieren auf Block-IDs statt auf lesbaren Titeln
Darum bevorzuge ich ein Python-Skript um den Export herum, das explizit Folgendes behandelt:
- Markdown-Export der aktuellen Seite
- Traversierung durch Unterseiten
- Traversierung durch Seiten in Unterdatenbanken
- ZIP-Extraktion für heruntergeladene Assets
- Umbenennung von Block-IDs in Seitentitel
Diese Grundform lohnt sich zu behalten:
import os
import zipfile
from notion_client import Client
from notion2md.exporter.block import MarkdownExporter
token = os.environ["NOTION_TOKEN"]
root_id = os.environ["NOTION_PAGE_ID"].replace("-", "")
notion = Client(auth=token)
def sanitize_title(title: str) -> str:
safe = "".join(
c for c in title
if c.isalnum() or c in (" ", "-", "_", "(", ")", "【", "】", "、", ".")
).rstrip()
return safe or "untitled"
def export_recursive(block_id: str, output_dir: str, title: str):
block_id = block_id.replace("-", "")
safe_title = sanitize_title(title)
os.makedirs(output_dir, exist_ok=True)
MarkdownExporter(block_id=block_id, output_path=output_dir, download=True).export()
zip_path = os.path.join(output_dir, f"{block_id}.zip")
if os.path.exists(zip_path):
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(output_dir)
os.remove(zip_path)
md_path = os.path.join(output_dir, f"{block_id}.md")
new_md_path = os.path.join(output_dir, f"{safe_title}.md")
if os.path.exists(md_path):
if os.path.exists(new_md_path):
os.remove(new_md_path)
os.rename(md_path, new_md_path)
results = []
next_cursor = None
while True:
res = notion.blocks.children.list(block_id=block_id, start_cursor=next_cursor)
results.extend(res.get("results", []))
next_cursor = res.get("next_cursor")
if not next_cursor:
break
child_dir = os.path.join(output_dir, safe_title)
for block in results:
if block["type"] == "child_page":
export_recursive(block["id"], child_dir, block["child_page"]["title"])
elif block["type"] == "child_database":
db_dir = os.path.join(child_dir, sanitize_title(block["child_database"]["title"]))
pages = notion.databases.query(database_id=block["id"]).get("results", [])
for page in pages:
page_title = "untitled"
for _, prop in page["properties"].items():
if prop["type"] == "title" and prop["title"]:
page_title = prop["title"][0]["plain_text"]
break
export_recursive(page["id"], db_dir, page_title)
Diese Ebene macht aus einem einfachen Export einen Backup-Workflow, den man dauerhaft behalten kann.
Warum das wartbarer ist als token_v2
Der wichtigste Unterschied ist nicht das Branding, sondern die Betriebsstabilität.
Ansätze mit token_v2 sind im Grunde temporärer Zugriff, der aus einer angemeldeten Browser-Session übernommen wurde. Eine Internal Integration ist dagegen eine explizite Service-Identität mit expliziten Berechtigungen. Für unbeaufsichtigte Automation ist das deutlich passender.
Zusammen mit GitHub Actions wirkt das Ganze damit wie ein echtes Wartungssystem statt wie ein Workaround.
Wo dieser Workflow am ehesten kaputtgeht
In der Praxis würde ich zuerst diese Punkte prüfen:
- das Token in Secrets ist falsch oder enthält störende Leerzeichen
- die Integration wurde erstellt, aber nie mit der Zielseite verbunden
- die Page ID wurde falsch kopiert
- das Export-Skript funktioniert für die Root-Seite, scheitert aber bei Rekursion, ZIP-Extraktion oder Umbenennung
Auch Rate Limits gehören zur Realität. Wenn ein Workspace viele verschachtelte Seiten oder große Mengen an Anhängen besitzt, bedeutet eine lange Laufzeit nicht automatisch, dass der Workflow kaputt ist. Manchmal braucht Traversierung und Download einfach Zeit.
Wenn GitHub Actions also längere Zeit in Run bleibt, zuerst die Logs ansehen, bevor die gesamte Pipeline für tot erklärt wird.
Was ich bei langfristiger Wartung beobachten würde
Nach dem ersten erfolgreichen Export ändert sich die Frage von „funktioniert es einmal?“ zu „bildet es echte Änderungen dauerhaft ab?“.
Ich würde darauf achten:
- ob im Repository weiterhin regelmäßig Backup-Commits auftauchen
- ob neu angelegte Unterseiten automatisch erscheinen
- ob Datenbankeinträge wie erwartet exportiert werden
- ob die Umbenennungslogik mit gemischten Sprachen und Sonderzeichen sauber umgeht
Wenn ich das System weiter verbessern müsste, wären meine ersten zwei kleinen Upgrades:
- Fehlermeldungen in der Action-Ausgabe expliziter machen
- die Struktur des Backup-Repositories sauber halten, damit das Archiv langfristig lesbar bleibt
Für wen dieser Ansatz gedacht ist – und für wen nicht
Gut passt er, wenn:
- Notion als ernsthafte Wissensbasis genutzt wird
- ein stabiler Backup-Pfad ohne Browser-Cookies gewünscht ist
- GitHub ohnehin genutzt wird und ein Repository die Export-Historie speichern darf
Wahrscheinlich übertrieben ist er, wenn:
- nur gelegentlich manuell exportiert wird
- GitHub Actions und Python komplett vermieden werden sollen
- Unterseiten, Datenbanken und eine saubere Ausgabestruktur keine Rolle spielen
Für einen gelegentlichen Einmal-Export reicht der eingebaute Notion-Export weiterhin. Automatisierung lohnt sich dann, wenn die eigenen Notizen so groß geworden sind, dass manueller Export bereits zu einer Routine wird, die man irgendwann auslässt.