WordPress

Victor Scrapper: Cómo hice un plugin WordPress para automatizar datos públicos

Compartir

Introducción

En este artículo te cuento cómo construí Victor Scrapper, un plugin de WordPress que extrae datos de una fuente pública y los integra en la web de mi equipo de fútbol. El objetivo era claro: automatizar la actualización de información sin tareas manuales, mantener la web siempre al día y no sacrificar rendimiento ni SEO.

No revelo la fuente concreta (por acuerdos internos), pero el enfoque es genérico y puede adaptarse a otras webs o deportes cambiando el mapeo de campos. Aquí encontrarás el código completo, la arquitectura y recomendaciones de rendimiento y SEO.

Arquitectura del plugin

  • Admin UI: menú propio en el dashboard para lanzar y revisar el scraping.
  • Campos personalizados (ACF/CPT): el CPT equipo define las URLs (url_scrapper, url_todos_los_partidos).
  • Scraping: petición HTTP a la URL pública y parseo del HTML/JSON.
  • Persistencia: guardado en metadatos del CPT.
  • AJAX/seguridad: acciones protegidas con nonce y roles.
  • Rendimiento: scraping diferido (cron/manual) + uso de caché/transients.

Flujo de scraping y guardado

  1. Configurar en el CPT equipo las URLs (url_scrapper y/o url_todos_los_partidos).
  2. Lanzar scraping desde el admin o programar WP-Cron.
  3. El plugin solicita la fuente, parsea datos y los transforma al esquema interno.
  4. Los resultados se guardan como metadatos.
  5. El front lee esos metadatos y los muestra de forma rápida.

Tip: evita scraping en la carga pública. Hazlo en segundo plano y cachea resultados.

Rendimiento y SEO

Rendimiento

  • Evita llamadas externas en el front.
  • Usa WP-Cron para actualizar en horarios de baja carga.
  • Implementa transients para cachear datos.
  • Optimiza assets, fuentes y lazy loading.

Instalación y uso

  1. Copia la carpeta victorscrapper en wp-content/plugins/.
  2. Activa el plugin en Plugins > Victor Scrapper.
  3. En el CPT equipo, añade url_scrapper y url_todos_los_partidos.
  4. Lanza el scraping o programa un cron.
  5. Usa los metadatos en tus plantillas para mostrar los datos.

Código completo

El siguiente código pertenece a un plugin de WordPress llamado **Victor Scrapper**, diseñado para realizar tareas de **Web Scraping** (raspado web) en sitios con datos publicos. Su propósito es automatizar la extracción de datos de partidos, clasificaciones y calendarios para publicarlos en campos personalizados (ACF) asociados a un *Custom Post Type* (CPT) de ‘equipo’.

Funciones de Scraping (victorscrapper/src/scrapper.php)

El núcleo del scraping reside en este archivo, que utiliza la librería estándar de PHP **DOMDocument** y **DOMXPath** para analizar el código HTML.


1. `scrapper_victor_on_match($post_id, $url)`: Extraer el Próximo Partido

Esta función está diseñada para extraer la información clave de un solo partido o el resultado más reciente, utilizando la URL específica proporcionada para el post de equipo. Se centra en la tabla que contiene los resultados.

Código PHP de la función:

<?php
function scrapper_victor_on_match($post_id, $url)
{
    echo $url;
    $response = wp_remote_get($url, array(
        'timeout' => 20,
        'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
    ));

    if (is_wp_error($response)) {
        return ['error' => 'Error al obtener la URL'];
    }

    $html = wp_remote_retrieve_body($response);

    if (empty($html)) {
        return ['error' => 'No se pudo obtener contenido HTML'];
    }

    $html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');

    libxml_use_internal_errors(true);
    $dom = new DOMDocument();
    $dom->loadHTML($html);
    libxml_clear_errors();

    $xpath = new DOMXPath($dom);
    $tables = $xpath->query(
        "//table[contains(concat(' ', normalize-space(@class), ' '), ' table_resultats ')][1]"
    );

    $partidos = [];

    foreach ($tables as $table) {
        foreach ($table->getElementsByTagName('tr') as $tr) {
            $fila = [];
            $tds = $tr->getElementsByTagName('td');

            $i = 0;
            foreach ($tds as $td) {
                $class = $td->getAttribute('class');



                // Equipo
                if (strpos($class, 'resultats-w-equip') !== false) {
                    $a = $td->getElementsByTagName('a')->item(0);
                    $key = $i === 1 ? 'equip' : 'equip2';
                    $fila[$key] = $a ? trim($a->textContent) : trim($td->textContent);

                    update_field('equipo_local',     $fila['equip'],  $post_id);
                    update_field('equipo_visitante', $fila['equip2'], $post_id);
                }

                // Resultado / fecha
                if (strpos($class, 'resultats-w-resultat') !== false) {
                    $value = trim(preg_replace('/\s+/', ' ', $td->textContent));

                    // Obtener el href
                    $a = $td->getElementsByTagName('a')->item(0);
                    $href = $a ? $a->getAttribute('href') : '';

                    update_field('dia_proximo_partido',     $value  ?? '', $post_id);
                    update_field('url_federacion',  $href ?? '', $post_id);
                }


                $i++;
            }
        }
    }
}

2. `scrapper_victor_classificacion($post_id, $url)`: Extraer Tabla de Clasificación

Se encarga de raspar todos los datos de la tabla de clasificación de la liga, incluyendo estadísticas detalladas (puntos, jugados, ganados, etc.), y los guarda en un campo repetidor de ACF.

Código PHP de la función:

<?php
function scrapper_victor_classificacion($post_id, $url)
{
$response = wp_remote_get($url, [
'timeout' => 20,
'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
]);

if (is_wp_error($response)) {
return ['error' => 'Error al obtener la URL'];
}

$html = wp_remote_retrieve_body($response);
if (empty($html)) {
return ['error' => 'No se pudo obtener contenido HTML'];
}

$html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');

libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML($html);
libxml_clear_errors();

$xpath = new DOMXPath($dom);
$tables = $xpath->query("//table[contains(concat(' ', normalize-space(@class), ' '), ' fcftable-e ')]");

$rows = [];

foreach ($tables as $table) {
foreach ($xpath->query('.//tbody/tr', $table) as $tr) {
$tds = $tr->getElementsByTagName('td');
if ($tds->length < 10) {
continue; // No parece una fila válida
}

$len = $tds->length;

$pos_cell = $tds->item(0);
$shield_cell = $tds->item(1);
$name_cell = $tds->item(2); // versión resumida
$points_cell = $tds->item(3);
$coef_cell = $tds->item(4); // coeficiente o segundo valor

$j_cell = $tds->item(6);
$g_cell = $tds->item(7);
$e_cell = $tds->item(8);
$p_cell = $tds->item(9);

$goles_favor_cell = $tds->item($len - 4);
$goles_contra_cell = $tds->item($len - 3);
$racha_cell = $tds->item($len - 2);
$sancion_cell = $tds->item($len - 1);

$pos_text = trim($pos_cell->textContent);
if (preg_match('/(\d+)/', $pos_text, $m)) {
$pos = (int)$m[1];
} else {
$pos = null;
}

$name_a = $name_cell->getElementsByTagName('a')->item(0);
$equipo = $name_a ? trim($name_a->textContent) : trim($name_cell->textContent);
$equipo_url = $name_a ? $name_a->getAttribute('href') : '';

$points_raw = trim($points_cell->textContent);
$puntos = null;
$puntos_extra = '';
if (preg_match('/^(\d+)(?:\s*\(([^)]+)\))?/', $points_raw, $m)) {
$puntos = (int)$m[1];
if (isset($m[2])) {
$puntos_extra = trim($m[2]);
}
} else {
$puntos = is_numeric($points_raw) ? (int)$points_raw : null;
}

$coeficiente = trim($coef_cell->textContent);

$jugados = (int)trim($j_cell->textContent);
$ganados = (int)trim($g_cell->textContent);
$empatados = (int)trim($e_cell->textContent);
$perdidos = (int)trim($p_cell->textContent);

$goles_favor = (int)trim($goles_favor_cell->textContent);
$goles_contra = (int)trim($goles_contra_cell->textContent);

$racha_span = null;
$racha = '';
$acta_url = '';
$anchor_racha = $racha_cell->getElementsByTagName('a')->item(0);
if ($anchor_racha) {
$acta_url = $anchor_racha->getAttribute('href');
foreach ($racha_cell->getElementsByTagName('span') as $sp) {
if (strpos($sp->getAttribute('class'), 'racha') !== false) {
$racha_span = $sp;
break;
}
}
}
if ($racha_span) {
$texto_racha = trim($racha_span->firstChild ? $racha_span->firstChild->textContent : $racha_span->textContent);
$racha = substr($texto_racha, 0, 1);
}

$sancion = trim($sancion_cell->textContent);

$rows[] = [
'pos' => $pos,
'equip' => $equipo,
'equip_url' => $equipo_url,
'punts' => $coef_cell->textContent,
'pj' => $jugados,
'ganados' => $ganados,
'empatados' => $empatados,
'perdidos' => $perdidos,
];
}
}

if (!empty($rows) && function_exists('update_field')) {
update_field('clasificacion', $rows, $post_id);
// Intento adicional por si el campo se creó con tilde (menos recomendable)
update_field('clasificacion', $rows, $post_id);
}

return $rows;
}

3. `scrapper_victor_all_match($post_id, $url)`: Extraer Todos los Partidos del Calendario

Esta función analiza un calendario completo, pero aplica un filtro estricto: solo guarda los partidos donde el equipo ‘ATICO’ sea local o visitante. Esto permite mantener un historial o calendario del equipo relevante.

Código PHP de la función:

<?php
function scrapper_victor_all_match($post_id, $url)
{
$response = wp_remote_get($url, array(
'timeout' => 20,
'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
));

if (is_wp_error($response)) {
return ['error' => 'Error al obtener la URL'];
}

$html = wp_remote_retrieve_body($response);

if (empty($html)) {
return ['error' => 'No se pudo obtener contenido HTML'];
}

$html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');

libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML($html);
libxml_clear_errors();

$xpath = new DOMXPath($dom);
$tables = $xpath->query("//table[contains(concat(' ', normalize-space(@class), ' '), ' calendaritable ')]");

$rows = [];

foreach ($tables as $table) {

$th2 = $xpath->query(".//thead/tr/th[2]", $table)->item(0);
$setmana = $th2 ? trim($th2->textContent) : '';

foreach ($xpath->query(".//tbody/tr", $table) as $tr) {

$links = $xpath->query(".//td/a[normalize-space(text())]", $tr);
if ($links->length < 2) continue;

$aLocal = $links->item(0);
$aVisitante = $links->item($links->length - 1);

$nombreLocal = trim($aLocal->textContent);
$urlLocal = $aLocal->getAttribute('href');
$nombreVisitante = trim($aVisitante->textContent);
$urlVisitante = $aVisitante->getAttribute('href');

if (
stripos($nombreLocal, 'ATICO') === false &&
stripos($nombreVisitante, 'ATICO') === false
) {
continue;
}

$rows[] = [
'nombre_equipo_local' => $nombreLocal,
'url_equipo_local' => $urlLocal,
'equipo_visitante' => $nombreVisitante,
'url_equipo_visitante' => $urlVisitante,
'setmana_de_' => $setmana, // 👈 nuevo subcampo
];
}
}

if (!empty($rows)) {
update_field('todos_los_partidos', $rows, $post_id);
} else {
// si quieres vaciar el repeater cuando no hay partidos de ATICO:
update_field('todos_los_partidos', [], $post_id);
}
}

Estructura del Plugin y Ejecución (victorscrapper/victorscrapper.php)

Este es el archivo principal que registra el plugin, crea las páginas de administración y, lo más importante, contiene la lógica para **ejecutar las funciones de scraping**.

Clase `Victor_Scrapper_Plugin`

El método `render_main_page()` es el punto donde se itera sobre los posts de tipo ‘equipo’ y se llama a cada una de las funciones de scraping, pasándoles las URLs configuradas mediante ACF.

Código PHP de la clase y ejecución:

<?php

/**
 * Plugin Name: Victor Scrapper
 * Description: Añade un menú principal y un submenú que muestran "hola".
 * Version: 1.0.0
 * Author: Víctor Gómez Luque
 * Text Domain: victor-scrapper
 */

if (! defined('ABSPATH')) exit; // Seguridad

class Victor_Scrapper_Plugin
{

    const MENU_SLUG  = 'victor-scrapper';
    const SUB_SLUG   = 'victor-scrapper-sub';

    public function __construct()
    {
        add_action('admin_menu', [$this, 'register_admin_menus']);

        // Endpoint AJAX para lanzar el scrapper por URL
        add_action('wp_ajax_victor_scrapper_run', [$this, 'ajax_run']);
        add_action('wp_ajax_nopriv_victor_scrapper_run', [$this, 'ajax_run']);
    }

    public function register_admin_menus()
    {
        // Menú principal
        add_menu_page(
            __('Victor Scrapper', 'victor-scrapper'),
            __('Victor Scrapper', 'victor-scrapper'),
            'manage_options',
            self::MENU_SLUG,
            [$this, 'render_main_page'],
            'dashicons-admin-tools',
            59
        );

        // Submenú
        add_submenu_page(
            self::MENU_SLUG,
            __('Victor Scrapper - Submenú', 'victor-scrapper'),
            __('Submenú', 'victor-scrapper'),
            'manage_options',
            self::SUB_SLUG,
            [$this, 'render_sub_page']
        );
    }

    public function render_main_page()
    {
        require_once 'src/scrapper.php';

        $query = new WP_Query([
            'post_type'      => 'equipo',
            'posts_per_page' => -1,
            'post_status'    => 'publish',
            'orderby'        => 'date',
            'order'          => 'DESC',
        ]);

        if ($query->have_posts()) :
            while ($query->have_posts()) : $query->the_post();

                $url_scrapper_on_match = get_field('url_scrapper', get_the_ID());
                $url_scrapper_all_match = get_field('url_todos_los_partidos', get_the_ID());
                $url_calendario = get_field('url_calendario', get_the_ID());
?>
                <div class="scrapper-victor">
                    <p><?php the_title(); ?></p>
                    <p><?php echo esc_html($url_scrapper_on_match); ?></p>
                    <p><?php echo esc_html($url_scrapper_all_match); ?></p>
                    <p><?php echo esc_html($url_calendario); ?></p>
                </div>
<?php
                scrapper_victor_on_match(get_the_ID(), $url_scrapper_on_match);
                scrapper_victor_all_match(get_the_ID(), $url_scrapper_all_match);
                scrapper_victor_classificacion(get_the_ID(), $url_calendario);

            endwhile;
        endif;
    }

    public function render_sub_page()
    {
        echo '<div class="wrap"><h1>Victor Scrapper — Submenú</h1><p>hola</p></div>';
    }

    /**
     * Lanza la misma lógica que el menú principal, pero accesible por AJAX
     */
    public function ajax_run()
    {

        // --- seguridad básica con token ---
        $token = isset($_GET['token']) ? sanitize_text_field($_GET['token']) : '';
        if ($token !== '1234') {
            wp_die('Acceso no autorizado', 403);
        }

        // Ejecutamos la misma función
        ob_start();
        $this->render_main_page();
        $output = ob_get_clean();

        // Devuelve el HTML generado
        wp_send_json_success($output);
    }
}

new Victor_Scrapper_Plugin();