acambronero
acambronero
@acambronero@blogpocket.es

Este es el blog federado de Antonio Cambronero, fundador, autor y CEO de Blogpocket. Informático, blogger y profesor, con más de 20 años de experiencia en departamentos de soporte técnico informático, análisis de sistemas, productividad, optimización de procesos, atención al cliente y formación, en empresas multinacionales.

108 publicaciones
50 seguidores

Cómo automatizar la migración de PDFs en una copia estática de WordPress

En un artículo anterior contaba cómo había generado una copia estática completa de mi blog: HTML, imágenes optimizadas a WebP, enlaces internos reescritos a rutas locales, todo desplegado en un subdominio de Blogpocket. Lo daba por cerrado. Sé que no lo estaba.

Cuando me puse a navegar la copia recién subida, descubrí que algunos posts tenían enlaces a ebooks en PDF que apuntaban a lanzatu.blog, el dominio anterior del blog. El plugin descarga imágenes pero no PDFs. Y como lanzatu.blog lo seguía manteniendo activo (con menos contenido que antes), los enlaces todavía funcionaban; pero era cuestión de tiempo que dejaran de hacerlo. Una copia estática que depende de un servidor externo no es una copia estática, es un placebo.

Así nació el capítulo PDFs. Esta es la historia.

El plan inicial (que duró cinco minutos)

Pensé en algo sencillo: un script que recorriera los HTML, encontrara los <a href="...pdf"> que apuntaran a dominios internos, descargara los PDFs a una carpeta local /pdf y reescribiera los enlaces. Tres fases, código predecible. Lo escribí en Python con la librería estándar, con su modo --dry-run para listar sin tocar, su confirmación interactiva, su rate-limiting suave para no martillear el servidor.

La primera ejecución detectó unos cuantos enlaces, los descargó, reescribió los HTML. Casi todo bien. Pero algo no encajaba: el script reportaba dos fallos con HTTP 404. Inspeccioné las URLs:

https://www.lanzatu.blog/twitter_y_politica_amores_y_odios.pdf
https://www.lanzatu.blog/wp-content/uploads/dlm_uploads/2022/04/La-historia-jamas-contada-blogpocket-21.pdf

El primero, de 2013, apuntaba a la raíz del dominio. Casi seguro que en algún momento de los últimos doce años cambié esa estructura y olvidé mantener el archivo. El segundo era de un PDF servido por el plugin Download Monitor, cuya carpeta /dlm_uploads/ había borrado yo mismo días atrás cuando hice limpieza. Es decir: dos PDFs perdidos por mis propios actos. Comprobación final con find por todo el disco: nada, no estaban en ninguna parte.

Sobre estos dos, decidí pragmáticamente: mantener el contenido del HTML, pero eliminar el <a> que lo envolvía. El lector ve el texto del enlace (o la imagen que enlazaba), sin la frustración del clic muerto. Un par de sed precisos resolvieron el problema:

# Caso 1: <a href="...pdf">texto</a> → texto
sed -i.bak -E 's|<a href="https://www\.lanzatu\.blog/twitter_y_politica_amores_y_odios\.pdf">([^<]+)</a>|\1|g' 2013/04/twitter-y-politica-amores-y-odios.html

# Caso 2: <a href="...pdf"><img/></a> → <img/>
sed -i.bak -E 's|<a [^>]*href="[^"]*La-historia-jamas-contada[^"]*\.pdf"[^>]*>([^<]*<img[^>]*/?>)</a>|\1|g' pages/descarga-ebooks.html

Pero esto fue solo el aperitivo.

Los bloques wp:file de Gutenberg, donde una sola URL son tres URLs

Al ampliar la búsqueda con un grep global sobre toda la copia, descubrí decenas de páginas más con referencias a PDFs en lanzatu.blog. Y al inspeccionar un caso concreto vi el patrón que me iba a tener entretenido un rato:

<!-- wp:file {"id":77201,"href":"https://www.lanzatu.blog/.../HABLANDO-WORDPRESS-4.pdf"} -->
<div class="wp-block-file">
  <object data="https://www.lanzatu.blog/.../HABLANDO-WORDPRESS-4.pdf" ...></object>
  <a href="../pdf/HABLANDO-WORDPRESS-4.pdf">HABLANDO-WORDPRESS-4</a>
  <a href="../pdf/HABLANDO-WORDPRESS-4.pdf" class="wp-block-file__button">Descarga</a>
</div>

El bloque «Archivo» de Gutenberg, que permite incrustar un PDF en un post con visor embebido y botones de descarga, mete tres referencias a la misma URL dentro del HTML:

  1. El comentario <!-- wp:file --> con los metadatos del bloque (que WordPress usa para serializar/deserializar el bloque en el editor).
  2. El atributo data del <object> (el visor PDF embebido).
  3. Los <a href> de los botones (el texto y el «Descarga»).

Mi script solo había reescrito el tercero, porque mi regex buscaba <a href="...pdf">. Los otros dos seguían apuntando al servidor original. Resultado: cuando un visitante abriera la página, el visor embebido intentaría cargar el PDF de lanzatu.blog y daría error.

Solución: una sustitución global más permisiva con sed, que cazara cualquier URL absoluta a un PDF en lanzatu.blog/wp-content/uploads/AÑO/MES/X.pdf y la reemplazara por ../pdf/X.pdf o ../../pdf/X.pdf según la profundidad del archivo:

# Para archivos a profundidad 1 (pages/)
sed -i.tmp -E 's#https?://(www\.)?lanzatu\.blog/wp-content/uploads/[0-9]+/[0-9]+/([^"]+\.pdf)#../pdf/\2#g' pages/*.html

# Para archivos a profundidad 2 (año/mes/)
sed -i.tmp -E 's#https?://(www\.)?lanzatu\.blog/wp-content/uploads/[0-9]+/[0-9]+/([^\\]+\.pdf)#../../pdf/\2#g' 2023/06/*.html

Cuando creía que ya estaba limpio, me topé con un caso aún más curioso.

URLs escapadas en JSON dentro de comentarios HTML

Un único archivo, presentando-los-threads.html, contenía un bloque de un plugin de Gutenberg llamado Thread Block que serializa sus secciones como un comentario HTML con un JSON dentro:

<!-- wp:buntywp/thread-item {"sections":[{"content":"Para celebrarlo, te invito a descargar mi ebook \u003ca href=\u0022https://lanzatu.blog/.../X.pdf\u0022\u003eFrom Pixel To Brush\u003c/a\u003e."}]} -->

Las comillas que delimitan la URL del PDF no son comillas literales, sino \u0022 (la representación Unicode escapada del carácter " en JSON). Mi sed anterior no la captaba porque buscaba terminar con ". La regex correcta cambia el terminador del path por \\ (la barra invertida del escape \u0022):

sed -i.tmp -E 's#https?://(www\.)?lanzatu\.blog/wp-content/uploads/[0-9]+/[0-9]+/([^\\]+\.pdf)#../../pdf/\2#g' 2025/05/presentando-los-threads.html

Esta es la clase de detalle que solo aparece cuando trabajas con casos reales sobre un blog con años de contenido acumulado. No es algo que se anticipe; aparece, le das forma, y sigues adelante.

El segundo descubrimiento: la biblioteca multimedia tiene PDFs huérfanos

Con todos los enlaces de los HTML resueltos y los PDFs en su carpeta /pdf/ local, miré la biblioteca multimedia de WordPress por curiosidad. Tenía 152 PDFs. Mi carpeta /pdf/ tenía 70.

La diferencia tiene sentido: son archivos que en algún momento subí a la biblioteca pero nunca insertí en un post o página. Documentos de prueba, versiones antiguas de un ebook, borradores que nunca llegaron a publicarse. Para un visitante de la copia estática no existen (nadie los va a encontrar porque no están enlazados desde ningún sitio), así que el script no los descarga. Eso es correcto: la copia estática refleja el blog tal como lo ven los visitantes, no la trastienda.

Pero como backup personal, sí los quería tener guardados. La forma manual sería ir a la biblioteca multimedia, abrir cada PDF, «Save link as…», repetir 80 veces. La forma sensata: un script que use la API REST de WordPress (/wp-json/wp/v2/media) para listar todos los attachments con mime_type=application/pdf y descargarlos uno a uno.

Lo escribí también en Python estándar. Tres detalles que merecen mención:

  1. Paginación: la API devuelve máximo 100 ítems por página. Hay que iterar leyendo la cabecera X-WP-TotalPages que dice cuántas hay en total.
  2. Application Passwords: si la API no devuelve resultados anónimamente, no hay que compartir la contraseña principal. WordPress permite crear «Application Passwords» desde Usuarios → Perfil → Application Passwords, que dan acceso programático limitado y se pueden revocar individualmente.
  3. Filtrado contra una carpeta existente: con --existing /ruta/a/pdf, el script compara nombres de archivo y solo descarga los que faltan. Útil cuando ya tienes parte del trabajo hecho con el primer script.

Ejecución contra lanzatu.blog:

Total de PDFs en la biblioteca: 152
Filtrando contra 70 PDFs ya presentes
  Saltados (ya en existing): 74
  Pendientes a descargar:    78

74 saltados con 70 ya presentes — la pequeña diferencia son archivos con el mismo nombre pero versiones distintas. El script salta por nombre (que es lo razonable para un backup), y deja constancia. Total descargado: 78 PDFs, 451 MB.

Aprendizajes (segunda parte)

Las bibliotecas de WordPress acumulan basura. Diez años publicando significan años subiendo archivos que se quedaron. Si nunca vas a hacer un backup completo, hazlo al menos una vez. Y guárdalo aparte de los backups del propio sitio.

Borrar archivos por FTP no actualiza la base de datos. Cuando borré la carpeta dlm_uploads/ del servidor para liberar espacio, los registros de la biblioteca multimedia siguieron ahí. La API REST sigue reportando esos archivos como existentes; las descargas devuelven 404. La forma correcta de eliminar archivos en WordPress es desde la biblioteca multimedia del admin, no por FTP.

Los plugins de Gutenberg meten metadatos JSON en comentarios HTML. Y a veces esos metadatos contienen URLs serializadas con escape Unicode. Cualquier herramienta que pretenda reescribir enlaces en HTML serializado de WordPress tiene que prever esto.

El Application Password es la forma correcta de automatizar contra WordPress. No es la contraseña principal: es un token específico para uso programático, revocable, sin acceso al panel. Si tienes que escribir un script que hable con la REST API de un WordPress, esa es la pieza que conviene conocer.

El plugin Copia Estática Local sigue creciendo. Estaba pensado para volcar HTML, descargar imágenes y reescribir enlaces. La nueva versión del repositorio ya incluye dos herramientas auxiliares más para la parte de los PDFs.

Los nuevos scripts están en la carpeta tools/ del repositorio en GitHub, con su documentación en tools/README.md. La versión 1.9 los empaqueta junto a los anteriores en un solo release.

Y ahora sí: la copia está completa.

Capítulo cerrado

De un script ad-hoc que no descargaba imágenes a un plugin publicado en GitHub, con cumplimiento del Plugin Check oficial de WordPress.org, cuatro scripts auxiliares para tareas adyacentes, una copia estática viva en blogpocket.com/copia-estatica/ con 76 PDFs locales y enlaces resueltos, un backup completo de la biblioteca multimedia y dos posts narrativos publicados.

Descarga el software desde mi cuenta de GitHub.

  • Plugin v1.8 (lógica idéntica).
  • Repo v1.9 con cuatro scripts en tools/ y README actualizado.
  • Release v1.9 publicada como Latest con copia-estatica-1.9.zip adjunto. Topics correctos (static-site-generator, wordpress, wordpress-plugin, backup, archive).
  • Description y Website configurados.
  • Languages reflejan PHP + Python + Shell.