¡Hola a todos! 👋 Si alguna vez has querido descargar todas las entradas de tu blog de WordPress para tener un respaldo local impecable, leer tus artículos sin conexión, o compartirlos de manera elegante como documentos independientes, estás en el lugar correcto.

Hoy te traigo una herramienta completa en Python diseñada desde cero para automatizar este proceso. Este script no solo extrae el contenido de tu API de WordPress, sino que soluciona los problemas más comunes al generar PDFs: coloca la imagen de portada arriba del todo, incrusta las imágenes de forma segura para evitar bloqueos del servidor, y aplica un resaltado de sintaxis a color increíble en tus bloques de código.

✨ Características Principales

  • 📸 Portada Inteligente con Doble Rescate: Busca automáticamente la imagen destacada oficial de WordPress (incluso consultando directamente el endpoint de medios si la API la oculta). Si un post no tiene imagen destacada, toma inteligentemente la primera imagen del cuerpo del artículo y la coloca arriba del título.
  • 🔒 Incrustación Base64 contra Bloqueos: Descarga las imágenes utilizando cabeceras personalizadas y las convierte a formato data:image/...;base64. Esto evita que firewalls, Cloudflare o configuraciones de red locales bloqueen las peticiones internas de WeasyPrint.
  • 🎨 Resaltado de Código Automático (Pygments): Analiza los bloques de código (<pre> y <code>), detecta automáticamente el lenguaje de programación (Python, Bash, PHP, JavaScript, etc.) o lo adivina de forma inteligente, aplicando un esquema de colores profesional (friendly) adaptado perfectamente para impresión.
  • 📂 Nombres de Archivos Seguros: Limpia títulos largos, elimina emojis y caracteres especiales convirtiéndolos en slugs limpios organizados por fecha (YYYY-MM-DD_nombre-del-post.pdf).

🛠️ Requisitos del Sistema y Entorno Virtual

Debido a las políticas de seguridad de las distribuciones modernas de Linux (que protegen el gestor global de paquetes de Python mediante PEP 668), lo más limpio, profesional y recomendado es trabajar dentro de un entorno virtual (venv).

1. Instalar dependencias del sistema operativo

Primero, asegúrate de tener instaladas las librerías tipográficas y de renderizado (necesarias para que WeasyPrint procese fuentes y emojis correctamente):

  • En Arch Linux / CachyOS:Bashsudo pacman -S pango noto-fonts-emoji ttf-liberation
  • En Linux Mint / Ubuntu:Bashsudo apt install libpango-1.0-0 libpangoft2-1.0-0 fonts-noto-color-emoji

2. Configurar el Entorno Virtual de Python

Abre tu terminal en la carpeta donde quieras trabajar y ejecuta los siguientes comandos:

Bash

# 1. Crear el entorno virtual llamado 'venv'
python3 -m venv venv

# 2. Activar el entorno virtual
source venv/bin/activate

(Verás que tu terminal cambia para mostrar (venv) al inicio, indicando que estás dentro del entorno aislado).

3. Instalar las librerías de Python

Con el entorno activo, instala las dependencias necesarias:

Bash

pip install requests beautifulsoup4 weasyprint pygments

💻 El Script Completo (blog_a_pdf.py)

Crea un archivo llamado blog_a_pdf.py, cópiale el siguiente contenido y guárdalo en tu directorio de trabajo:

Python

#!/usr/bin/env python3
"""
Exporta cada entrada de un blog WordPress (de un año dado) a un PDF individual
con portada arriba, incrustación Base64 y resaltado de sintaxis a color.

Uso:
    python3 blog_a_pdf.py              # año actual
    python3 blog_a_pdf.py --year 2026
    python3 blog_a_pdf.py --year 2026 --out ./pdfs

Dependencias (dentro de tu venv):
    pip install requests beautifulsoup4 weasyprint pygments
"""
import argparse
import base64
import html
import re
import time
import unicodedata
from datetime import datetime
from pathlib import Path
from urllib.parse import unquote, urljoin

import requests
from bs4 import BeautifulSoup
from weasyprint import HTML

from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.lexers.special import TextLexer
from pygments.util import ClassNotFound

# CONFIGURACIÓN DE TU SITIO WEB
SITE = "https://cesarsystems.com.mx"
API = f"{SITE}/wp-json/wp/v2/posts"
HEADERS = {"User-Agent": "Mozilla/5.0 (blog-a-pdf)"}

# Generar automáticamente los estilos CSS de colores para Pygments (Tema: friendly)
PYGMENTS_CSS = HtmlFormatter(style="friendly").get_style_defs('.highlight')

CSS = f"""
@page {{
    size: Letter;
    margin: 2cm 1.8cm;
    @bottom-center {{ content: counter(page) " / " counter(pages); font-size: 9pt; color: #777; }}
}}
body {{ font-family: "Liberation Sans", "DejaVu Sans", "Noto Color Emoji", sans-serif;
       font-size: 10.5pt; line-height: 1.5; color: #222; }}
h1 {{ font-size: 20pt; margin: 0.4em 0 .2em; color: #12355b; }}
h2 {{ font-size: 15pt; margin-top: 1.4em; color: #12355b; border-bottom: 1px solid #ccd; padding-bottom: 2px; }}
h3 {{ font-size: 12.5pt; margin-top: 1.2em; }}
.meta {{ color: #666; font-size: 9pt; margin-bottom: 1.2em; }}
.meta a {{ color: #666; word-break: break-all; }}
img {{ max-width: 100%; height: auto; }}
img.portada {{ display: block; width: 100%; max-height: 9cm; object-fit: cover;
              border-radius: 4px; margin: 0 0 1.2em; }}
pre {{ background: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 12px;
      font-size: 8.5pt; white-space: pre-wrap; word-wrap: break-word; page-break-inside: auto; }}
code {{ font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; font-size: 9pt; }}
p code, li code {{ background: #eef0f3; padding: 2px 5px; border-radius: 4px; color: #d63384; }}
table {{ border-collapse: collapse; width: 100%; margin: 1em 0; }}
th, td {{ border: 1px solid #bbb; padding: 4px 6px; font-size: 9.5pt; vertical-align: top; }}
th {{ background: #eef0f3; }}
blockquote {{ border-left: 3px solid #99a; margin-left: 0; padding-left: 10px; color: #444; }}
a {{ color: #1a5fb4; }}

/* Estilos de resaltado de código Pygments */
{PYGMENTS_CSS}
.highlight pre {{ background: transparent; border: none; padding: 0; margin: 0; }}
"""

PYGMENTS_FORMATTER = HtmlFormatter(cssclass="highlight")


def slugify(texto: str, max_len: int = 70) -> str:
    """Genera nombres de archivos seguros sin acentos, espacios ni caracteres extraños."""
    texto = unicodedata.normalize("NFKD", texto).encode("ascii", "ignore").decode()
    texto = re.sub(r"[^a-zA-Z0-9]+", "-", texto).strip("-").lower()
    return texto[:max_len].strip("-")


def obtener_posts(year: int) -> list[dict]:
    """Descarga de forma paginada todas las entradas publicadas en el año indicado."""
    posts, page = [], 1
    while True:
        params = {
            "after": f"{year}-01-01T00:00:00",
            "before": f"{year + 1}-01-01T00:00:00",
            "per_page": 100,
            "page": page,
            "orderby": "date",
            "order": "asc",
            "_embed": "wp:featuredmedia",
            "_fields": "id,date,slug,link,title,content,featured_media,_embedded",
        }
        r = requests.get(API, params=params, headers=HEADERS, timeout=60)
        if r.status_code == 400:  # Fin de paginación
            break
        r.raise_for_status()
        lote = r.json()
        if not lote:
            break
        posts.extend(lote)
        total_pages = int(r.headers.get("X-WP-TotalPages", 1))
        if page >= total_pages:
            break
        page += 1
    return posts


def limpiar_contenido(html_contenido: str) -> str:
    """Limpia etiquetas innecesarias, corrige lazy-loads y aplica colores al código."""
    soup = BeautifulSoup(html_contenido, "html.parser")

    for tag in soup(["script", "style", "iframe", "noscript", "form"]):
        tag.decompose()

    for img in soup.find_all("img"):
        for attr in ("data-src", "data-lazy-src", "data-orig-file"):
            if img.get(attr):
                img["src"] = img[attr]
                break
        for attr in ("srcset", "sizes", "loading"):
            img.attrs.pop(attr, None)

    # Procesar bloques de código con Pygments
    for pre in soup.find_all("pre"):
        code_tag = pre.find("code")
        code_text = code_tag.get_text() if code_tag else pre.get_text()
        
        lang = None
        classes = pre.get("class", []) + (code_tag.get("class", []) if code_tag else [])
        for c in classes:
            if c.startswith("language-") or c.startswith("brush:") or c.startswith("lang-"):
                lang = c.replace("language-", "").replace("brush:", "").replace("lang-", "").strip()
        
        lexer = None
        if lang:
            try:
                lexer = get_lexer_by_name(lang)
            except ClassNotFound:
                pass
        
        if not lexer:
            try:
                lexer = guess_lexer(code_text)
            except Exception:
                lexer = TextLexer()
        
        highlighted_code = highlight(code_text, lexer, PYGMENTS_FORMATTER)
        new_tag = BeautifulSoup(highlighted_code, "html.parser")
        pre.clear()
        pre.append(new_tag)

    return str(soup)


def obtener_url_portada(post: dict) -> str | None:
    """Rescata la imagen destacada oficial (vía embebidos o API de medios) o usa la del contenido."""
    media_id = post.get("featured_media", 0)

    if media_id > 0:
        # 1. Intentar desde datos embebidos
        try:
            media = post["_embedded"]["wp:featuredmedia"][0]
            if isinstance(media, dict):
                sizes = media.get("media_details", {}).get("sizes", {})
                for nombre in ("large", "full"):
                    if nombre in sizes and sizes[nombre].get("source_url"):
                        return sizes[nombre]["source_url"]
                if media.get("source_url"):
                    return media["source_url"]
        except (KeyError, IndexError, TypeError):
            pass

        # 2. Consultar directamente el endpoint de medios si lo anterior falló
        try:
            r_media = requests.get(f"{SITE}/wp-json/wp/v2/media/{media_id}", headers=HEADERS, timeout=10, verify=False)
            if r_media.status_code == 200:
                data = r_media.json()
                sizes = data.get("media_details", {}).get("sizes", {})
                for nombre in ("large", "full"):
                    if nombre in sizes and sizes[nombre].get("source_url"):
                        return sizes[nombre]["source_url"]
                if data.get("source_url"):
                    return data["source_url"]
        except Exception:
            pass

    # 3. Fallback: primera imagen dentro del cuerpo del texto
    soup = BeautifulSoup(post["content"]["rendered"], "html.parser")
    img_tag = soup.find("img")
    if img_tag:
        for attr in ("src", "data-src", "data-lazy-src", "data-orig-file"):
            url = img_tag.get(attr)
            if url:
                return urljoin(SITE, url)

    return None


def url_a_base64(url: str) -> str | None:
    """Descarga la imagen remota y la convierte a Data URI en Base64."""
    try:
        r = requests.get(url, headers=HEADERS, timeout=15, verify=False)
        if r.status_code == 200:
            content_type = r.headers.get("Content-Type", "image/jpeg")
            encoded = base64.b64encode(r.content).decode("utf-8")
            return f"data:{content_type};base64,{encoded}"
    except Exception:
        pass
    return None


def armar_html(post: dict) -> str:
    """Ensambla el HTML final colocando la portada arriba, seguida del título, metadatos y contenido."""
    titulo = html.unescape(post["title"]["rendered"])
    fecha = datetime.fromisoformat(post["date"]).strftime("%d/%m/%Y")
    cuerpo = limpiar_contenido(post["content"]["rendered"])
    
    url_img = obtener_url_portada(post)
    portada = ""
    if url_img:
        img_b64 = url_a_base64(url_img)
        if img_b64:
            portada = f'<img class="portada" src="{img_b64}">'

    return f"""<!DOCTYPE html>
<html lang="es"><head><meta charset="utf-8"><title>{html.escape(titulo)}</title></head>
<body>
{portada}
<h1>{html.escape(titulo)}</h1>
<div class="meta">{fecha} &middot; <a href="{post['link']}">{unquote(post['link'])}</a></div>
{cuerpo}
</body></html>"""


def main():
    import urllib3
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

    ap = argparse.ArgumentParser()
    ap.add_argument("--year", type=int, default=datetime.now().year)
    ap.add_argument("--out", default="pdfs_blog")
    args = ap.parse_args()

    out = Path(args.out)
    out.mkdir(parents=True, exist_ok=True)

    print(f"[*] Descargando entradas de {args.year} desde {SITE} ...")
    posts = obtener_posts(args.year)
    print(f"[✓] {len(posts)} entradas encontradas.\n")

    for i, post in enumerate(posts, 1):
        titulo = html.unescape(post["title"]["rendered"])
        nombre = f"{post['date'][:10]}_{slugify(titulo) or post['id']}.pdf"
        destino = out / nombre

        if destino.exists():
            print(f"[{i}/{len(posts)}] Ya existe, se omite: {nombre}")
            continue

        print(f"[{i}/{len(posts)}] Procesando: {nombre}")
        try:
            HTML(string=armar_html(post), base_url=SITE).write_pdf(
                destino, stylesheets=[__import__("weasyprint").CSS(string=CSS)]
            )
        except Exception as err:
            print(f"    [-] Error al generar PDF: {err}")
        time.sleep(0.5)

    print(f"\n[✓] ¡Listo! Todos los PDFs se guardaron en: {out.resolve()}")


if __name__ == "__main__":
    main()

🕹️ Guía de Uso del Script

Una vez que tengas configurado tu entorno virtual y tu archivo listo, puedes ejecutar el script con diferentes opciones según tus necesidades:

  • Exportar las entradas del año actual por defecto:Bashpython3 blog_a_pdf.py
  • Exportar un año en específico (ejemplo, año 2026):Bashpython3 blog_a_pdf.py --year 2026
  • Guardar los PDFs en una carpeta personalizada:Bashpython3 blog_a_pdf.py --year 2026 --out ./mis_respaldos_pdf

Cuando termines de trabajar, simplemente recuerda desactivar tu entorno virtual escribiendo:

Bash

deactivate