# Cómo hacer copias de Notion en GitHub con la API oficial y GitHub Actions

> Canonical: https://vxi.me/es/posts/notion-api-github-actions-backup/
> Locale: es-es
> Published: 2026-03-27
> Updated: 2026-03-27
> Markdown: https://vxi.me/es/posts/notion-api-github-actions-backup.md

Si alguna vez has intentado hacer una copia de seguridad seria de Notion, probablemente hayas chocado con uno de estos dos problemas. O dependías de tokens no oficiales del navegador que terminaban caducando, o conseguías una exportación aparentemente correcta hasta descubrir que solo había capturado la página superior y que las subpáginas, bases de datos, adjuntos y nombres de archivos seguían siendo un desastre.

La configuración que terminé conservando es mucho más estable: usar la API oficial de Notion, darle una Internal Integration adecuada y dejar que GitHub Actions ejecute la exportación de forma programada. Con eso desaparecen tres problemas habituales de una vez: no hay autenticación basada en cookies, no dependes de que un equipo local permanezca encendido y tienes un lugar limpio donde ampliar el flujo con recursión y renombrado de archivos.

Si buscas términos como `Notion backup to GitHub`, `Notion GitHub Actions backup` o `export Notion to Markdown with the official API`, este artículo trata exactamente ese problema.

## Por qué dejé de depender de backups con tokens no oficiales

Los métodos no oficiales pueden funcionar, sobre todo para una prueba rápida. El problema aparece más adelante.

Todo parece correcto hasta que ocurre una de estas cosas:

- la sesión del navegador caduca
- el token deja de ser válido
- otra IP o entorno empieza a devolver 401 o 403
- la exportación sigue ejecutándose, pero el alcance de datos es incompleto

La API oficial arregla primero la capa más frágil:

- la autenticación se basa en un token de Internal Integration, no en una sesión prestada del navegador
- el acceso a las páginas es explícito, así que puedes saber por qué una página es legible o no
- GitHub Actions aporta un scheduler y logs predecibles desde el principio

Lo que todavía tienes que construir es la parte práctica: recorrido recursivo, manejo de adjuntos y una salida legible.

## Cómo queda el flujo final de backup

La cadena completa se entiende mejor de arriba abajo:

```text
Página raíz de Notion
        ↓
Autorización de Internal Integration
        ↓
Ejecución programada de GitHub Actions
        ↓
Script Python con notion-client + notion2md
        ↓
Recorrido recursivo de subpáginas y bases de datos hijas
        ↓
Exportación Markdown + descarga de adjuntos
        ↓
Extracción ZIP
        ↓
Renombrar archivos basados en block-id con los títulos reales
        ↓
Commit de todo de vuelta a GitHub
```

GitHub Actions es solo el runner. El valor real está en la capa de Python intermedia, porque el camino de exportación estándar todavía no produce un archivo recursivo completo con la estructura que la mayoría quiere en la práctica.

## Configura primero bien la autorización oficial

Empieza en la consola de desarrolladores de Notion y crea una Internal Integration:

1. Abre [Notion Integrations](https://www.notion.so/my-integrations)
2. Crea una nueva integración y selecciona `Internal`
3. Dale al menos `Read content`
4. Copia el token que comienza por `secret_`

Ese token por sí solo no basta. Los permisos de Notion son explícitos, lo cual es bueno, pero significa que la integración no puede leer una página hasta que la conectes con ella.

Para la página raíz que quieres guardar:

1. abre la página en Notion
2. pulsa el menú `...`
3. elige `Add connections`
4. añade la integración que acabas de crear

Si falta ese paso, el patrón de fallo más común es sencillo:

- el token parece válido
- el page ID parece válido
- la API sigue diciendo que no encuentra el bloque

También necesitas el ID de la página raíz. Normalmente son los 32 caracteres que aparecen en la URL de la página, con o sin guiones.

## En GitHub bastan dos Secrets

No pondría los secretos directamente dentro del workflow. Lo limpio es guardarlos como repository secrets:

- `NOTION_INTEGRATION_TOKEN`
- `NOTION_PAGE_ID`

Después crea:

```text
.github/workflows/backup.yml
```

El workflow exterior no necesita ser complicado. Solo tiene que:

- hacer checkout del repositorio
- configurar Python
- instalar `notion-client` y `notion2md`
- ejecutar el script de exportación
- commitear el resultado si cambió el contenido

Una versión mínima sería:

```yaml
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 "action@github.com"
          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
```

Con esto la automatización ya es real. La parte interesante sigue siendo el script de exportación.

## Lo importante es la exportación recursiva, no el wrapper del workflow

El problema habitual de una exportación basada en la API oficial no es que falle del todo. Es que termina demasiado pronto.

Si solo exportas una página, normalmente acabas con:

- la página raíz exportada
- subpáginas ausentes
- bases de datos hijas convertidas en problemas separados
- nombres basados en IDs de bloque en lugar de títulos legibles

Por eso prefiero envolver la exportación con un script Python que gestione explícitamente:

- exportación Markdown de la página actual
- recorrido de subpáginas
- recorrido de páginas dentro de bases de datos hijas
- extracción ZIP de los assets descargados
- renombrado de IDs de bloque a títulos de página

Esta es la estructura central que merece la pena conservar:

```python
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)
```

Esta capa es la que convierte una exportación básica en un flujo de backup que realmente puedes mantener.

## Por qué esto es más mantenible que `token_v2`

La diferencia principal no es de branding, sino de estabilidad operativa.

Los enfoques basados en `token_v2` no son más que acceso temporal tomado de una sesión de navegador ya iniciada. Una Internal Integration es una identidad de servicio explícita con permisos explícitos. Encaja mucho mejor con automatización desatendida.

Cuando lo combinas con GitHub Actions, el sistema empieza a sentirse como mantenimiento real en lugar de un workaround.

## Dónde es más probable que falle

En la práctica revisaría primero:

- que el token guardado en Secrets sea correcto y no tenga espacios extraños
- que la integración exista, pero también esté añadida a la página objetivo
- que el page ID esté bien copiado
- que el script funcione en la raíz pero no falle durante recursión, extracción ZIP o renombrado

También hay que asumir los límites de tasa. Si tu workspace tiene muchas páginas anidadas o muchos adjuntos, una ejecución larga no significa automáticamente que el workflow esté roto. A veces el recorrido y las descargas son simplemente lentos.

Si GitHub Actions se queda un rato en `Run`, mira los logs antes de asumir que toda la pipeline ha muerto.

## Qué vigilaría a largo plazo

Tras la primera exportación correcta, la pregunta cambia de “¿funciona una vez?” a “¿sigue capturando cambios reales con el tiempo?”.

Vigilaría:

- que el repositorio siga recibiendo commits de backup con regularidad
- que las nuevas subpáginas aparezcan automáticamente
- que las entradas de bases de datos se exporten como esperas
- que la lógica de renombrado se comporte bien con idiomas mezclados y caracteres especiales

Si tuviera que mejorarlo, empezaría por dos cambios pequeños:

1. hacer más explícitos los logs de error en la salida del Action
2. mantener limpia la estructura del repositorio de backup para que el archivo siga siendo legible con el tiempo

## Para quién encaja y para quién no

Encaja bien si:

- usas Notion como una base de conocimiento seria
- quieres un camino de backup estable que no dependa de cookies del navegador
- ya utilizas GitHub y no te importa que un repositorio conserve el historial de exportación

Probablemente es excesivo si:

- solo exportas manualmente de vez en cuando
- no quieres tocar GitHub Actions ni Python
- no te preocupan las subpáginas, bases de datos o una estructura de salida más limpia

Si solo necesitas una exportación puntual, el exportador integrado de Notion sigue siendo suficiente. La automatización empieza a merecer la pena cuando tus notas han crecido tanto que exportar manualmente ya se ha convertido en una tarea que probablemente terminarás saltándote.
