Automatización de Respaldo de Blogs con Java, HtmlUnit y Markdown

Para garantizar la preservación de publicaciones en plataformas de blogs, es fundamental implementar un mecanismo de respaldo automatizado. Este proceso implica la extracción de datos web, la conversión de contenido HTML a formato Markdown, la descarga de recursos multimedia y el almacenamiento estructurado en una base de datos relacional.

Configuración del Entorno y Dependencias

El núcleo de la solución se basa en HtmlUnit para la navegación y parsing de DOM, Hutool para utilidades de red y base de datos, y el conector de MySQL. A continuación, se detallan las dependencias necesarias para el gestor de proyectos Maven:

<dependencies>
    <!-- Motor de renderizado y scraping -->
    <dependency>
        <groupId>net.sourceforge.htmlunit</groupId>
        <artifactId>htmlunit</artifactId>
        <version>2.67.0</version>
    </dependency>

    <!-- Conector para base de datos relacional -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.30</version>
    </dependency>

    <!-- Suite de utilidades Java -->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.11</version>
    </dependency>
</dependencies>

Esquema de Base de Datos

Los metadatos y el contenido procesado se almacenan en una tabla MySQL. Es crucial utilizar utf8mb4 para soportar emojis y caracteres especiales, y MEDIUMTEXT para artículos extensos.

CREATE TABLE `blog_archives` (
  `article_id` INT NOT NULL AUTO_INCREMENT COMMENT 'Identificador único',
  `headline` VARCHAR(255) CHARACTER SET utf8mb4 NULL COMMENT 'Título de la publicación',
  `markdown_body` MEDIUMTEXT CHARACTER SET utf8mb4 NULL COMMENT 'Contenido transformado',
  `publish_timestamp` DATETIME NULL COMMENT 'Fecha de emisión',
  `read_metrics` INT DEFAULT 0 COMMENT 'Total de visualizaciones',
  `interaction_count` INT DEFAULT 0 COMMENT 'Total de comentarios',
  PRIMARY KEY (`article_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Almacén de respaldos de artículos';

Extracción de Enlaces y Paginación

El primer paso consiste en recorrer el índice del blog para recopilar las URLs de cada entrada. Se implementa un retardo aleatorio para evitar saturar el servidor de origen.

private static List<String> fetchArticleLinks(WebClient browser, String baseUrl, int currentPage) throws IOException, InterruptedException {
    List<String> collectedLinks = new ArrayList<>();
    HtmlPage domPage = browser.getPage(baseUrl + currentPage);

    // Extracción de nodos de título
    for (DomNode titleNode : domPage.querySelectorAll("div.postTitle")) {
        DomNode linkNode = titleNode.querySelector("a.postTitle2");
        if (linkNode != null) {
            collectedLinks.add(linkNode.getAttribute("href"));
        }
    }

    // Lógica de recursión para paginación
    DomNode footer = domPage.querySelector("div.topicListFooter");
    if (footer != null && footer.asNormalizedText().contains("Siguiente")) {
        Thread.sleep(RandomUtil.randomInt(1500, 3000)); // Pausa de cortesía
        collectedLinks.addAll(fetchArticleLinks(browser, baseUrl, currentPage + 1));
    }

    return collectedLinks;
}

Transformación de HTML a Markdown

El núcleo del procesamiento analiza el árbol DOM del artículo. Se mapean encabezados, bloques de código, tablas y elementos en línea a su sintaxis equivalente en Markdown. Las imágenes se interceptan para su descarga local y posterior reescritura de rutas.

private static Map<String, Object> processAndConvert(WebClient browser, String targetUrl, boolean saveImagesLocally) throws Exception {
    HtmlPage page = browser.getPage(targetUrl);
    Map<String, Object> metadata = new HashMap<>();

    metadata.put("headline", page.querySelector("a#cb_post_title_url").asNormalizedText());
    metadata.put("publish_timestamp", page.querySelector("span#post-date").asNormalizedText());
    metadata.put("read_metrics", page.querySelector("span#post_view_count").asNormalizedText());
    metadata.put("interaction_count", page.querySelector("span#post_comment_count").asNormalizedText());

    StringBuilder mdBuilder = new StringBuilder();
    List<DomNode> elements = new ArrayList<>(page.querySelector("div#cnblogs_post_body").getChildNodes());

    for (int i = 0; i < elements.size(); i++) {
        DomNode node = elements.get(i);
        String tagName = node.getNodeName();
        String cssClass = node.getAttributes().getNamedItem("class") != null ? 
                           node.getAttributes().getNamedItem("class").getTextContent() : "";

        if ("h2".equals(tagName)) {
            mdBuilder.append("## ").append(node.asNormalizedText()).append("\n\n");
        } else if ("h3".equals(tagName)) {
            mdBuilder.append("### ").append(node.asNormalizedText()).append("\n\n");
        } else if ("div".equals(tagName) && "cnblogs_code".equals(cssClass)) {
            mdBuilder.append("```text\n").append(node.asNormalizedText()).append("\n```\n\n");
        } else if ("table".equals(tagName)) {
            parseHtmlTable(node, mdBuilder);
        } else if (node.asXml().contains("<img")) {
            handleImages(node, mdBuilder, saveImagesLocally);
        } else if (node.asXml().contains("<a")) {
            handleHyperlinks(node, mdBuilder);
        } else {
            String text = node.asNormalizedText().trim();
            if (!text.isEmpty()) {
                mdBuilder.append(text).append("\n\n");
            }
        }
    }

    metadata.put("markdown_body", mdBuilder.toString());
    return metadata;
}

Manejo de Recursos Multimedia

Las imágenes incrustadas requieren un tratamiento especial. Se extrae el atributo src, se descarga el binario utilizando las utilidades HTTP y se actualiza la referencia en el documento Markdown.

private static void handleImages(DomNode container, StringBuilder builder, boolean download) throws InterruptedException {
    for (DomNode imgNode : container.querySelectorAll("img")) {
        String originalSrc = imgNode.getAttribute("src");
        if (originalSrc == null || originalSrc.isEmpty()) continue;

        if (download) {
            String fileName = originalSrc.substring(originalSrc.lastIndexOf("/") + 1);
            File localFile = new File("assets/images/" + fileName);
            if (!localFile.exists()) {
                Thread.sleep(RandomUtil.randomInt(800, 1500));
                HttpUtil.downloadFile(originalSrc, localFile);
            }
            builder.append("![image](assets/images/").append(fileName).append(")\n\n");
        } else {
            builder.append("![image](").append(originalSrc).append(")\n\n");
        }
    }
}

Ejecución Principal y Persistencia

El método principal orquesta la inicialización del navegador headless, la obtención de enlaces, el procesamiento iterativo y la inserción en la base de datos mediante sentencias parametrizadas.

public static void main(String[] args) {
    try (WebClient headlessBrowser = new WebClient(BrowserVersion.FIREFOX)) {
        headlessBrowser.getOptions().setJavaScriptEnabled(false);
        headlessBrowser.getOptions().setCssEnabled(false);
        headlessBrowser.getOptions().setTimeout(15000);

        String blogIndexUrl = "https://example-blog-platform.com/user/default.html?page=";
        List<String> allUrls = fetchArticleLinks(headlessBrowser, blogIndexUrl, 1);

        SimpleDataSource dataSource = new SimpleDataSource(
            "jdbc:mysql://localhost:3306/blog_db?useSSL=false", "db_user", "secure_password"
        );
        Db dbOperator = Db.use(dataSource);
        dbOperator.execute("TRUNCATE TABLE blog_archives");

        int recordId = 1;
        for (String articleUrl : allUrls) {
            Map<String, Object> articleData = processAndConvert(headlessBrowser, articleUrl, true);
            
            // Persistencia en base de datos
            articleData.put("article_id", recordId++);
            dbOperator.execute(
                "INSERT INTO blog_archives (article_id, headline, markdown_body, publish_timestamp, read_metrics, interaction_count) " +
                "VALUES (:article_id, :headline, :markdown_body, :publish_timestamp, :read_metrics, :interaction_count)", 
                articleData
            );

            Thread.sleep(RandomUtil.randomInt(4000, 7000));
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

Etiquetas: java HtmlUnit Hutool MySQL markdown

Publicado el 7-26 07:12