Pipeline de Scraping — Arquitectura
Visión general
Spider
│ yield QuestionItem / dict / ItemSubclass
▼
DeerdaysScraperPipeline ← solo procesa QuestionItem
├── Deduplicación (SHA-256)
├── Validación por tipo (NUMERIC / BINARY)
└── Inserción en PostgreSQL
├── questions
└── question_options
Los items que no son QuestionItem salen directamente del pipeline sin procesarse; Scrapy los exporta según el FEEDS configurado en cada spider (JSON, CSV…).
Items definidos (items.py)
QuestionItem — preguntas para el juego
El único item que persiste en la base de datos. Todos los campos que la BD necesita deben venir rellenos desde la spider.
| Campo | Tipo | Descripción |
|---|---|---|
question_text |
str | Enunciado de la pregunta |
question_type |
str | NUMERIC o BINARY |
correct_value |
float | None | Respuesta numérica correcta (solo NUMERIC) |
unit |
str | None | Unidad del valor (ej. "km", "goles") |
tolerance |
float | None | Margen de error aceptable (solo NUMERIC) |
options |
list[dict] | None | Lista de opciones para BINARY |
source_url |
str | URL de origen del dato |
difficulty |
str | EASY, MEDIUM o HARD |
category |
str | Categoría temática (ej. "futbol", "cine") |
Estructura de una opción BINARY:
{"text": "Lionel Messi", "is_correct": True}
{"text": "Cristiano Ronaldo", "is_correct": False}
Otros item types (sin persistencia en BD)
| Item | Campos principales | Spider que lo usa |
|---|---|---|
SocialMediaCreatorItem |
name, platform, subscribers, image |
rrss |
WorldStatsItem |
metric, value, source |
worldometers |
CoronavirusItem |
metric, value, source |
worldometers |
CountryPopulationItem |
rank, country, population_2026 |
worldometers |
CountryWaterItem |
country, yearly_water_used |
worldometers |
DeerdaysScraperPipeline (pipelines.py)
Ciclo de vida
open_spider()
├── Conecta a PostgreSQL (psycopg2)
├── Resuelve spider_id por nombre en tabla `spiders`
└── Crea registro en `spider_runs` con status RUNNING
process_item(item) ← llamado por cada item yieldeado
├── Si no es QuestionItem → return item (pasa de largo)
├── Deduplicación: hash SHA-256 del question_text
│ └── Si ya existe en BD → DropItem
├── Validación:
│ ├── NUMERIC: correct_value no puede ser None
│ └── BINARY: al menos una opción con is_correct=True
├── INSERT en `questions` con status = 'PENDING_REVIEW'
└── INSERT en `question_options` (solo BINARY)
close_spider()
└── Actualiza `spider_runs`: finish_time, questions_scraped, errors_count
y resetea status de spider a IDLE
Deduplicación
Se calcula SHA-256(question_text.strip().lower()) y se guarda en un set en memoria (seen_hashes). Antes de insertar se comprueba también en BD para resistir reinicios. Esto hace que los reruns de una spider sean idempotentes: no generan duplicados.
Estados de una pregunta tras el scraping
PENDING_REVIEW ← estado inicial; visible en el panel de moderación
APPROVED ← moderador aprueba; entra en rotación de juego
REJECTED ← moderador rechaza; no se usa
Configuración (settings.py)
| Parámetro | Valor | Descripción |
|---|---|---|
BOT_NAME |
versus_scraper |
Identificador del bot |
USER_AGENT |
Chrome 91 / Win10 | Cabecera User-Agent enviada |
ROBOTSTXT_OBEY |
False (global) |
Cada spider puede sobreescribir con custom_settings |
ITEM_PIPELINES |
DeerdaysScraperPipeline: 300 |
Prioridad 300 (menor número = mayor prioridad) |
ASYNCIO_EVENT_LOOP |
AsyncioSelectorReactor |
Necesario para scrapy-playwright |
FEED_EXPORT_ENCODING |
utf-8 |
Codificación de archivos exportados |
Variables de entorno para la BD:
DB_HOST (default: localhost)
DB_PORT (default: 5432)
DB_NAME (default: versus)
DB_USER (default: versus)
DB_PASSWORD (default: versus)
Tablas de BD involucradas
Ver esquema completo en docs/bd-scheme.md.
spiders
Registro de cada spider conocida. La columna name debe coincidir exactamente con el atributo name de la clase Scrapy.
| Columna | Tipo | Descripción |
|---|---|---|
id |
UUID | PK |
name |
varchar | Nombre Scrapy de la spider |
status |
enum | IDLE / RUNNING / FAILED |
last_run |
timestamp | Última ejecución |
spider_runs
Historial de ejecuciones con métricas.
| Columna | Tipo | Descripción |
|---|---|---|
id |
UUID | PK |
spider_id |
UUID | FK → spiders |
start_time |
timestamp | Inicio de la ejecución |
finish_time |
timestamp | Fin (null mientras RUNNING) |
questions_scraped |
int | Preguntas insertadas en este run |
errors_count |
int | Errores de validación/pipeline |
Integración con el panel de administración (Sprint 4)
El panel admin lanzará spiders a través de la API REST:
POST /api/admin/spiders/{name}/run → dispara la spider remotamente
GET /api/admin/spiders → lista spiders y su estado
GET /api/admin/spiders/{id}/runs → historial de ejecuciones
Estos endpoints están documentados en docs/guia-de-coordinación-técnica.md pero aún no están implementados.
Cómo convertir datos crudos en QuestionItem
La mayoría de spiders actuales extraen datos crudos (nombres, cifras). Para que esos datos generen preguntas de juego hay que añadir una capa de transformación dentro de la spider o en un middleware.
Ejemplo — convertir un goleador en pregunta NUMERIC:
from versus_scraper.items import QuestionItem
def parse_row(self, row):
yield QuestionItem(
question_text=f"¿Cuántos goles marcó {row['player']} en su carrera?",
question_type="NUMERIC",
correct_value=float(row["goals"]),
unit="goles",
tolerance=0,
options=None,
source_url=self.start_urls[0],
difficulty="MEDIUM",
category="futbol",
)
Ejemplo — comparación BINARY con dos opciones:
yield QuestionItem(
question_text="¿Quién tiene más seguidores en YouTube?",
question_type="BINARY",
correct_value=None,
unit=None,
tolerance=None,
options=[
{"text": creator_a["name"], "is_correct": creator_a["subscribers"] > creator_b["subscribers"]},
{"text": creator_b["name"], "is_correct": creator_b["subscribers"] > creator_a["subscribers"]},
],
source_url="https://socialblade.com",
difficulty="EASY",
category="rrss",
)