Migrando Gist a código embebido en mi blog de WordPress

Desde hace algún tiempo he dejado de usar gist.github.com para embeber código fuente en mi blog y en su lugar he incrustado el código directamente.

Utilicé los Gists de GitHub por mucho tiempo y muchísimos posts lo contenían, pero en días anteriores me decidí a dejar de usarlo para hacer mi sitio más rápido, así que hoy vengo a compartir cómo es que hice la migración.

Remover Gists de WordPress

Dentro de mi blog tenía varios Gists que eran simples URLS con el formato "%https://gist.github.com/parzibyte/%". Entonces para removerlos, lo que tenía que hacer es:

  • Buscar aquellos posts de mi blog (leyendo la base de datos de MySQL) donde estuviera presente al menos una URL con ese formato
  • Usar expresiones regulares para extraer el hash de cada Gist
  • Obtener el contenido y extensión del archivo contenidos en el Gist usando la API de GitHub
  • Reemplazar el contenido después de que la API hubiera respondido

Hice todo este proceso con PHP, guardando el contenido original y los detalles en una base de datos SQLite3 que más adelante me sirvió para saber cuáles posts había actualizado y así actualizar la fecha de actualización, ya que lo olvidé en el primer paso.

Buscando posts que coinciden

Para este punto bastaba con hacer una consulta a la base de datos de WordPress buscando aquellos posts que estuvieran publicados y que contuvieran la URL previamente mencionada. Lo hice así:

function obtenerPosts()
{
    $baseDeDatos = obtenerBdMySQL();
    $consulta = 'select
    ID,
    post_content
from
    wp_posts
where
    post_type = "post"
    and post_status = "publish"
    and (
        post_content like "%https://gist.github.com/parzibyte/%"
    )
limit
    100;';
    return $baseDeDatos->query($consulta)->fetchAll(PDO::FETCH_OBJ);
}

Lo hice con 100 posts en cada paso, así no gastaba mucha memoria. Simplemente estoy consultando con where leyendo wp_posts donde el contenido del post (post_content) cumpliera con la expresión regular de un Gist.

Observa que estoy obteniendo el ID y el contenido del post.

Extraer cada hash presente en el post

Una vez que tenía la lista de posts a actualizar, tenía que extraer los hashes de cada Gist, ya que un post podía contener uno o más gists. Lo hice con preg_replace_callback ya que además de extraer cada hash tenía que consultar la API de GitHub para indicar el nuevo contenido:

function reemplazar($contenido, $idPost)
{
    $patron = '/https:\/\/gist\.github\.com\/parzibyte\/([a-z0-9]+)/m';
    return preg_replace_callback($patron, function ($coincidencias) use ($idPost) {
        // Coincidencias tiene la URL completa del gist
        // en el índice 0 y el hash en el índice 1
        $hash = $coincidencias[1];
        $fragmento = obtenerFragmentoDeCodigoSegunHash($hash);
        if ($fragmento === null) {
            throw new Exception("El fragmento es nulo");
        }
        registrarContenidoDeGist($hash, $fragmento, $idPost);
        return $fragmento;
    }, $contenido);
}

De este modo, cada URL de un Gist era reemplazado por su contenido gracias a la función obtenerFragmentoDeCodigoSegunHash que veremos a continuación. En dicha función es en donde se hace el consumo de la API de GitHub.

Consumir API de GitHub para obtener contenido del Gist

Veamos ahora la función para obtener el fragmento de código según el hash de su Gist. En este caso GitHub tiene una API muy bien documentada y que puede consumirse totalmente gratis, incluso sin autentificación.

Primero intenté consumirla sin ningún token, pero había un límite, así que me decidí por conseguir un Personal Access Token y enviarlo en el encabezado de la petición. Todo eso dentro de la función para obtener el contenido de un Gist con la API:

function obtenerContenidoDeGistConApi($hash)
{
    $url = "https://api.github.com/gists/$hash";
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'User-Agent: parzibyte',
        'Authorization: Bearer github_pat_123456798',
    ]);
    $output = curl_exec($ch);
    $response = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($response === 200) {
        return json_decode($output);
    }
    printf("Código %d al consultar %s\n", $response, $url);
    return null;
}

Estoy usando cURL dentro de PHP para consumir la API de GitHub, ya que viene integrado en la mayoría de entornos y es una herramienta totalmente probada. La función que acabas de ver es la encargada de obtener los detalles del Gist, pero la siguiente es la que indica el código HTML con todo y clase para el lenguaje de programación:

function obtenerFragmentoDeCodigoSegunHash($hash)
{
    $detalles = obtenerContenidoDeGistConApi($hash);
    if ($detalles === null) {
        return null;
    }
    $archivos = $detalles->files;
    $fragmentoDeCodigo = "";
    foreach ($archivos as $nombreArchivo => $archivo) {
        $contenido = $archivo->content;
        $truncado = $archivo->truncated;
        if ($truncado) {
            return null;
        }
        $fragmentoDeCodigo .= sprintf(
            '<pre><code class="%s">%s</code></pre>',
            obtenerClaseDeHTMLSegunExtension(pathinfo($nombreArchivo, PATHINFO_EXTENSION)),
            htmlspecialchars($contenido)
        );
    }
    return $fragmentoDeCodigo;
}

Como puedes ver tengo la función que obtiene la clase del HTML según la extensión del archivo devuelta por la API de GitHub, esta función es un simple diccionario que tiene todas las posibles clases según la extensión.

Por cierto, como el contenido de WordPress es HTML, hay que escapar el código con htmlspecialchars antes de regresar el fragmento de código. Esta función en específico es la que se encarga de convertir una URL a una cadena HTML que tiene un pre y un code.

Reemplazar contenido

Una vez que tenía el nuevo contenido a partir de la API, hacía la actualización en la base de datos de WordPress, registrando cada acción:

function actualizarPost($contenido, $id)
{
    $baseDeDatos = obtenerBdMySQL();
    $sentencia = $baseDeDatos->prepare("UPDATE wp_posts SET post_content = ? WHERE ID = ?");
    return $sentencia->execute([$contenido, $id]);
}

Finalmente la función que hace todo el proceso e invoca a las otras funciones es la siguiente:

function reemplazarTodos()
{
    $posts = obtenerPosts();
    foreach ($posts as $post) {
        $contenidoAnterior = $post->post_content;
        $idPost = $post->ID;
        try {
            printf("Reemplazando con ID %d...", $idPost);
            $nuevo = reemplazar($contenidoAnterior, $idPost);
            registrarReemplazo($idPost, $contenidoAnterior, $nuevo);
            actualizarPost($nuevo, $idPost);
            printf("OK. Se puede ver en https://parzibyte.me/blog/?p=%d\n", $idPost);
        } catch (Exception $e) {
            printf("No se pudo reemplazar\n");
        }
    }
}

Poniendo todo junto

Te he mostrado el código más importante, pero si quieres ver el código fuente completo lo dejaré a continuación. Toma en cuenta que en este caso he dejado el diccionario de extensiones de archivo truncado, ya que si lo dejo completo sería muy largo, pero puedes modificarlo según tus necesidades.

<?php

function obtenerClaseDeHTMLSegunExtension($extension)
{
    $diccionario = [
        'html' => 'language-markup',
        'xml' => 'language-markup',
    ];

    // Comprobación para regresar una cadena vacía si la extensión no está en el diccionario
    if (!isset($diccionario[$extension])) {
        return 'language-none';
    }

    return $diccionario[$extension];
}


function obtenerFragmentoDeCodigoSegunHash($hash)
{
    $detalles = obtenerContenidoDeGistConApi($hash);
    if ($detalles === null) {
        return null;
    }
    $archivos = $detalles->files;
    $fragmentoDeCodigo = "";
    foreach ($archivos as $nombreArchivo => $archivo) {
        $contenido = $archivo->content;
        $truncado = $archivo->truncated;
        if ($truncado) {
            return null;
        }
        $fragmentoDeCodigo .= sprintf(
            '<pre><code class="%s">%s</code></pre>',
            obtenerClaseDeHTMLSegunExtension(pathinfo($nombreArchivo, PATHINFO_EXTENSION)),
            htmlspecialchars($contenido)
        );
    }
    return $fragmentoDeCodigo;
}
function reemplazar($contenido, $idPost)
{
    $patron = '/https:\/\/gist\.github\.com\/parzibyte\/([a-z0-9]+)/m';
    return preg_replace_callback($patron, function ($coincidencias) use ($idPost) {
        // Coincidencias tiene la URL completa del gist
        // en el índice 0 y el hash en el índice 1
        $hash = $coincidencias[1];
        $fragmento = obtenerFragmentoDeCodigoSegunHash($hash);
        if ($fragmento === null) {
            throw new Exception("El fragmento es nulo");
        }
        registrarContenidoDeGist($hash, $fragmento, $idPost);
        return $fragmento;
    }, $contenido);
}


function extraerHashDeGist($texto)
{

    $patron = '/https:\/\/gist\.github\.com\/parzibyte\/([a-z0-9]+)/m';
    $coincidencias = array();
    preg_match_all($patron, $texto, $coincidencias, PREG_OFFSET_CAPTURE);
    return $coincidencias;
}

function obtenerContenidoDeGistConApi($hash)
{
    $url = "https://api.github.com/gists/$hash";
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'User-Agent: parzibyte',
        'Authorization: Bearer github_pat_123456798',
    ]);
    $output = curl_exec($ch);
    $response = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($response === 200) {
        return json_decode($output);
    }
    printf("Código %d al consultar %s\n", $response, $url);
    return null;
}

function obtenerBd()
{
    $baseDeDatos = new PDO("sqlite:" . __DIR__ . "/reemplazos.db");
    $baseDeDatos->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    return $baseDeDatos;
}

function crearTablas()
{
    $bd = obtenerBd();
    $bd->exec("CREATE TABLE IF NOT EXISTS reemplazos
    (idPost INTEGER,
    contenidoAnterior TEXT,
    contenidoNuevo TEXT
    );");
    $bd->exec("CREATE TABLE IF NOT EXISTS contenidos_gists
    (hash TEXT,
    contenido TEXT,
    idPost INTEGER
    );");
}

function registrarReemplazo($idPost, $contenidoAnterior, $contenidoNuevo)
{
    $baseDeDatos = obtenerBd();
    $sentencia = $baseDeDatos->prepare("INSERT INTO reemplazos(idPost, contenidoAnterior, contenidoNuevo) VALUES (?, ?, ?)");
    $sentencia->execute([$idPost, $contenidoAnterior, $contenidoNuevo]);
}

function registrarContenidoDeGist($hash, $contenido, $idPost)
{
    $baseDeDatos = obtenerBd();
    $sentencia = $baseDeDatos->prepare("INSERT INTO contenidos_gists(hash, contenido, idPost) VALUES (?, ?, ?)");
    $sentencia->execute([$hash, $contenido, $idPost]);
}

function reemplazarTodos()
{
    $posts = obtenerPosts();
    foreach ($posts as $post) {
        $contenidoAnterior = $post->post_content;
        $idPost = $post->ID;
        try {
            printf("Reemplazando con ID %d...", $idPost);
            $nuevo = reemplazar($contenidoAnterior, $idPost);
            registrarReemplazo($idPost, $contenidoAnterior, $nuevo);
            actualizarPost($nuevo, $idPost);
            printf("OK. Se puede ver en https://parzibyte.me/blog/?p=%d\n", $idPost);
        } catch (Exception $e) {
            printf("No se pudo reemplazar\n");
        }
    }
}

function obtenerBdMySQL()
{

    $contraseña = "hunter2";
    $usuario = "parzibyte";
    $nombre_base_de_datos = "wordpress";
    return new PDO('mysql:host=localhost;dbname=' . $nombre_base_de_datos . ";charset=utf8mb4", $usuario, $contraseña, [

        PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4"
    ]);
}

function actualizarPost($contenido, $id)
{
    $baseDeDatos = obtenerBdMySQL();
    $sentencia = $baseDeDatos->prepare("UPDATE wp_posts SET post_content = ? WHERE ID = ?");
    return $sentencia->execute([$contenido, $id]);
}

function obtenerPosts()
{
    $baseDeDatos = obtenerBdMySQL();
    $consulta = 'select
    ID,
    post_content
from
    wp_posts
where
    post_type = "post"
    and post_status = "publish"
    and (
        post_content like "%https://gist.github.com/parzibyte/%"
    )
limit
    100;';
    return $baseDeDatos->query($consulta)->fetchAll(PDO::FETCH_OBJ);
}

function main()
{
    crearTablas();
    reemplazarTodos();
}

main();

Y de este modo es como actualicé 1803 posts que contenían 6117 gists. Ese proceso me habría llevado muchísimo tiempo de haberlo hecho a mano, pero al final gracias a la programación lo pude resolver en algunas horas.

Bonus: actualizando fecha

Como lo dije al principio del post: cuando removí los Gists de mi blog, no guardé la nueva fecha de actualización. Gracias a que guardé todo el proceso en la base de datos de SQLite3, pude actualizar las fechas más adelante con la siguiente función de PHP:

function actualizarFechas()
{
    $posts = obtenerPostsPreviamenteActualizados();
    $bdMysql = obtenerBdMySQL();
    foreach ($posts as $post) {
        $ahora = date("Y-m-d H:i:s");
        $ahoraGmt = gmdate("Y-m-d H:i:s");
        $sentencia = $bdMysql->prepare("UPDATE wp_posts SET post_modified = ?, post_modified_gmt = ? WHERE id = ?");
        $sentencia->execute([$ahora, $ahoraGmt, $post->id]);
        printf("Guardando post con id %d. Su post_modified será %s y su post_modified_gmt será %s\n", $post->id, $ahora, $ahoraGmt);
        guardarPostActualizado($post->id);
    }
}

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *