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
equipodefine 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
noncey roles. - Rendimiento: scraping diferido (cron/manual) + uso de caché/transients.
Flujo de scraping y guardado
- Configurar en el CPT
equipolas URLs (url_scrappery/ourl_todos_los_partidos). - Lanzar scraping desde el admin o programar WP-Cron.
- El plugin solicita la fuente, parsea datos y los transforma al esquema interno.
- Los resultados se guardan como metadatos.
- 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
- Copia la carpeta
victorscrapperen wp-content/plugins/. - Activa el plugin en Plugins > Victor Scrapper.
- En el CPT equipo, añade
url_scrapperyurl_todos_los_partidos. - Lanza el scraping o programa un cron.
- 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();

