Backup di Notion su GitHub con API ufficiale e GitHub Actions
Se hai mai provato a fare sul serio il backup di Notion, probabilmente hai incontrato uno di due problemi. O ti sei affidato a token non ufficiali del browser che prima o poi sono scaduti, oppure hai ottenuto un’export apparentemente riuscito e solo dopo ti sei accorto che aveva catturato soltanto la pagina principale, mentre sottopagine, database, allegati e nomi file erano ancora un caos.
La configurazione che ho finito per mantenere è molto più stabile: usare l’API ufficiale di Notion, darle una vera Internal Integration e lasciare che GitHub Actions esegua l’export a intervalli regolari. Questo elimina subito tre problemi comuni: niente più autenticazione basata su cookie, nessuna dipendenza da un computer locale che deve restare acceso e un posto pulito in cui estendere il flusso con ricorsione e rinomina dei file.
Se stai cercando cose come Notion backup to GitHub, Notion GitHub Actions backup o export Notion to Markdown with the official API, questo articolo parla esattamente di quel problema.
Perché ho smesso di usare backup basati su token non ufficiali
I metodi non ufficiali possono funzionare, soprattutto per una prova rapida. Il problema arriva dopo.
Il workflow sembra andare bene finché non succede una di queste cose:
- la sessione del browser scade
- il token viene invalidato
- un IP o un ambiente diverso inizia a restituire 401 o 403
- l’export continua a partire, ma il perimetro dei dati è incompleto
L’API ufficiale sistema prima lo strato più fragile:
- l’autenticazione usa un token di Internal Integration, non una sessione del browser presa in prestito
- l’accesso alle pagine è esplicito, quindi sai perché una pagina è leggibile oppure no
- GitHub Actions fornisce scheduler e log prevedibili già pronti
Quello che devi ancora costruire è la parte pratica: attraversamento ricorsivo, gestione degli allegati e output leggibile.
Come appare il flusso finale di backup
È più facile ragionare sull’intera catena guardandola dall’alto verso il basso:
Pagina Notion principale
↓
Autorizzazione Internal Integration
↓
Esecuzione schedulata GitHub Actions
↓
Script Python con notion-client + notion2md
↓
Attraversamento ricorsivo di sottopagine e database figli
↓
Export Markdown + download allegati
↓
Estrazione ZIP
↓
Rinominare i file block-id con i veri titoli pagina
↓
Commit di tutto su GitHub
GitHub Actions qui è soltanto il runner. Il vero valore è nel livello Python intermedio, perché il percorso standard di esportazione non produce ancora un archivio ricorsivo completo nella forma che normalmente si vuole davvero.
Sistema prima l’autorizzazione ufficiale
Parti dalla console sviluppatori di Notion e crea una Internal Integration:
- Apri Notion Integrations
- Crea una nuova integrazione e scegli
Internal - Concedile almeno
Read content - Copia il token che inizia con
secret_
Quel token da solo non basta. I permessi Notion sono espliciti, il che è positivo, ma significa anche che l’integrazione non può leggere una pagina finché non la colleghi a quella pagina.
Per la pagina principale che vuoi salvare:
- apri la pagina in Notion
- fai clic sul menu
... - scegli
Add connections - aggiungi l’integrazione appena creata
Se salti questo passaggio, il pattern di errore più comune è semplice:
- il token sembra valido
- il page ID sembra valido
- l’API continua a dire che il blocco non può essere trovato
Ti serve anche l’ID della pagina root. Di solito è l’ID da 32 caratteri presente nell’URL della pagina, con o senza trattini.
Sul lato GitHub bastano due Secrets
Non inserirei i segreti direttamente nel file workflow. La versione pulita è salvarli come repository secrets:
NOTION_INTEGRATION_TOKENNOTION_PAGE_ID
Poi crea:
.github/workflows/backup.yml
Il workflow esterno non deve essere complicato. Deve soltanto:
- fare checkout del repository
- configurare Python
- installare
notion-clientenotion2md - eseguire lo script di export
- fare commit del risultato se il contenuto è cambiato
Una versione minimale è questa:
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
Questo basta a rendere reale l’automazione. La parte interessante rimane lo script di export.
La parte importante è l’export ricorsivo, non il wrapper del workflow
Il problema tipico dell’export basato sull’API ufficiale non è che fallisca del tutto. È che si ferma troppo presto.
Se esporti una sola pagina, spesso finisci con:
- pagina root esportata
- sottopagine mancanti
- database figli trattati come problemi separati
- nomi file basati su block ID invece che su titoli leggibili
Per questo preferisco avvolgere l’export in uno script Python che gestisca esplicitamente:
- export Markdown della pagina corrente
- attraversamento delle sottopagine
- attraversamento delle pagine dentro i database figli
- estrazione ZIP degli asset scaricati
- rinomina da block ID a titoli pagina
Questa è la forma centrale che vale la pena conservare:
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)
È questo livello a trasformare un export basilare in un workflow di backup che puoi davvero mantenere.
Perché è più manutenibile di token_v2
La differenza principale non è nel branding. È nella stabilità operativa.
Gli approcci in stile token_v2 sono essenzialmente accesso temporaneo preso da una sessione browser autenticata. Una Internal Integration è invece una vera identità di servizio con permessi espliciti. È molto più adatta all’automazione non presidiata.
Una volta combinata con GitHub Actions, l’intera soluzione inizia a sembrare un vero sistema di manutenzione invece di un workaround.
Dove è più probabile che il workflow si rompa
In pratica controllerei prima questi punti:
- il token in Secrets è sbagliato o contiene spazi indesiderati
- l’integrazione è stata creata ma non aggiunta alla pagina target
- il page ID è stato copiato male
- lo script funziona per la root ma fallisce durante ricorsione, estrazione ZIP o rinomina
Esiste anche il tema dei rate limit. Se il workspace contiene molte pagine annidate o molti allegati, una durata più lunga non significa automaticamente che il workflow sia rotto. A volte attraversamento e download sono semplicemente lenti.
Quindi, se GitHub Actions resta su Run per un po’, controlla i log prima di concludere che l’intera pipeline sia morta.
Cosa terrei d’occhio sul lungo periodo
Dopo il primo export riuscito, la domanda cambia da “può funzionare una volta?” a “continua a seguire nel tempo i cambiamenti reali?”.
Controllerei:
- che il repository continui a ricevere commit di backup regolari
- che le nuove sottopagine compaiano automaticamente
- che le voci dei database vengano esportate come previsto
- che la logica di rinomina gestisca bene lingue miste e caratteri speciali
Se dovessi migliorare ulteriormente il sistema, partirei da due piccoli upgrade:
- rendere più espliciti i log di errore nell’output dell’Action
- mantenere pulita la struttura del repository di backup, così l’archivio resta leggibile nel tempo
Per chi è adatto e per chi no
È una buona soluzione se:
- usi Notion come vera knowledge base
- vuoi un percorso di backup stabile che non dipenda dai cookie del browser
- usi già GitHub e ti va bene che un repository conservi la cronologia degli export
Probabilmente è eccessivo se:
- esporti manualmente solo ogni tanto
- non vuoi toccare per niente GitHub Actions o Python
- non ti interessano sottopagine, database o una struttura di output più pulita
Se ti serve solo un export occasionale, la funzione integrata di Notion va ancora bene. L’automazione inizia a valere la pena quando le note sono cresciute abbastanza da trasformare l’export manuale in una routine che prima o poi finirai per saltare.