Universidad Nacional de Luján
Departamento de Ciencias Básicas
Seminario de Integración Profesional 2026 Dr. David Petrocelli
📑 Índice del documento

Trabajo Práctico Nº 1 — Parte 2

Robustez, Tests, Docker, Kubernetes y CI/CD sobre el scraper de MercadoLibre

Fecha de Entrega: 02/05/2026

Pre-requisitos:


Requisitos, consideraciones y formato de entrega

Aplican los mismos requisitos generales de la Parte 1, más los siguientes:

Infra base obligatoria — bloqueante

🚧 Sin esto no se puede evaluar la entrega. El resto de los hits se valida a través de esta infra (la cátedra corre tu pipeline + tu docker compose up + tu Job en k8s para corregir Hits 4-8). Si la infra no funciona, no se llega a corregir nada más → nota 0. No suma puntos en la rúbrica porque es condición necesaria para que la entrega exista.

Otros requisitos


Contenidos del programa relacionados


Práctica

En la Parte 1 construyeron un scraper multi-browser que busca productos en MercadoLibre y aplica filtros vía DOM (Hits #1–#3). En esta Parte 2 lo llevamos a calidad de producción: empezamos por la extracción estructurada a JSON de los 3 productos (Hit #4), y luego sumamos robustez ante fallos, modo headless, tests automatizados, empaquetado en Docker, y pipeline de CI/CD que ejecute todo con cada push.

Continuamos con los mismos productos:

  1. Bicicleta rodado 29
  2. iPhone 16 Pro Max
  3. GeForce RTX 5090

Hit #4

Generalice el flujo del Hit #3 para que reciba como entrada una lista de productos (los 3 del enunciado: bicicleta rodado 29, iPhone 16 Pro Max, GeForce RTX 5090) y los procese todos.

Para cada producto, extraiga los primeros 10 resultados filtrados y, por cada uno, capture los siguientes campos:

Guarde la salida en archivos separados por producto, en formato JSON:

El JSON debe ser un array de objetos, uno por resultado, con los campos definidos arriba.


Hit #5

Endurezca el scraper para que sea robusto frente a fallos parciales:

  1. Si un campo opcional no aparece en un resultado (ej: producto sin cuotas), el scraper debe registrar null y continuar — no debe romper la ejecución.
  2. Si un selector falla por timeout, registre el error en el log con contexto (qué producto, qué browser, qué selector) y continúe con el siguiente resultado.
  3. Implemente un mecanismo de reintentos con backoff ante fallos transitorios de carga (ej: 3 intentos con 2s, 4s, 8s).
  4. Estructure los selectores en un módulo aparte (constantes con nombres semánticos), de modo que un cambio de DOM en MercadoLibre se arregle en un solo lugar.

Hit #6

Agregue modo headless controlable por variable de entorno (HEADLESS=true).

Escriba un set de tests automatizados (pytest / JUnit / Jest) que validen:

Los tests deben correr en CI tanto en Chrome como en Firefox.

Cobertura mínima: 70 %. Configure el reporte de cobertura (coverage.py + pytest-cov, jest --coverage, jacoco) y agregue una etapa al pipeline de CI que falle si la cobertura cae debajo del 70 %. Publique el reporte HTML como artifact del workflow.


Hit #7 — Despliegue en Kubernetes (k3s)

Pre-requisito: haber completado el TP 0 y tener un cluster k3s o k3d funcional.

Empaquete el scraper construido en la Infra base como una carga de trabajo de Kubernetes. El objetivo es ir más allá de “corre en mi máquina con Docker” y demostrar que la solución se puede desplegar en un orquestador.

8.1 — Job one-off

Cree k8s/job.yaml que ejecute el scraper una vez contra los 3 productos en headless Chrome, escribiendo los JSON en un volumen persistente.

8.2 — CronJob programado

Cree k8s/cronjob.yaml que ejecute el mismo scraping cada hora (0 * * * *), conservando un histórico en el volumen persistente.

8.3 — ConfigMap con configuración

Externaliza BROWSER, HEADLESS, LOG_LEVEL y la lista de productos a buscar en un k8s/configmap.yaml. Tanto el Job como el CronJob deben leer su configuración desde ahí.

8.4 — PersistentVolumeClaim para los outputs

Cree k8s/pvc.yaml que solicite 1 GB con la storage class local-path (que k3s trae out-of-the-box). Los JSON y screenshots se escriben acá.

Esqueleto orientativo

# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: scraper-config
data:
  BROWSER: "chrome"
  HEADLESS: "true"
  LOG_LEVEL: "INFO"
  PRODUCTS: |
    bicicleta rodado 29
    iPhone 16 Pro Max
    GeForce RTX 5090

---
# k8s/pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: scraper-output
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: local-path
  resources:
    requests:
      storage: 1Gi

---
# k8s/job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: scraper-once
spec:
  backoffLimit: 2
  template:
    spec:
      restartPolicy: OnFailure
      containers:
      - name: scraper
        image: ml-scraper:latest
        imagePullPolicy: IfNotPresent
        envFrom:
        - configMapRef:
            name: scraper-config
        volumeMounts:
        - name: output
          mountPath: /app/output
      volumes:
      - name: output
        persistentVolumeClaim:
          claimName: scraper-output

---
# k8s/cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: scraper-hourly
spec:
  schedule: "0 * * * *"
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: OnFailure
          containers:
          - name: scraper
            image: ml-scraper:latest
            imagePullPolicy: IfNotPresent
            envFrom:
            - configMapRef:
                name: scraper-config
            volumeMounts:
            - name: output
              mountPath: /app/output
          volumes:
          - name: output
            persistentVolumeClaim:
              claimName: scraper-output

Recetario de ejecución

# 1. Construir la imagen Docker (igual que en Infra base)
docker build -t ml-scraper:latest .

# 2. Cargar la imagen en el cluster
# Si usás k3s nativo:
docker save ml-scraper:latest -o ml-scraper.tar
sudo k3s ctr images import ml-scraper.tar
rm ml-scraper.tar

# Si usás k3d:
k3d image import ml-scraper:latest -c scraper

# 3. Aplicar todos los manifiestos
kubectl apply -f k8s/

# 4. Disparar el Job one-off y seguir los logs
kubectl get jobs
kubectl logs -l job-name=scraper-once -f

# 5. Inspeccionar el PVC y verificar los JSON
kubectl get pvc
kubectl exec -it $(kubectl get pod -l job-name=scraper-once -o jsonpath='{.items[0].metadata.name}') -- ls /app/output

# 6. Verificar el CronJob
kubectl get cronjobs
kubectl get jobs --watch  # vas a ver corridas cada hora

# 7. Cleanup
kubectl delete -f k8s/

Entregables del Hit #7


Hit #8 — Capacidad extendida

Extienda el scraper con las siguientes 3 capacidades:

  1. Paginación: traer los primeros 30 resultados en lugar de 10, navegando hasta 3 páginas.

  2. Comparación de precios: para cada producto, calcular precio mínimo, máximo, mediana y desvío estándar entre los resultados extraídos. Imprimir tabla resumen.

  3. Histórico con PostgreSQL: guardar los resultados en una instancia PostgreSQL con timestamp, para detectar cambios de precio entre corridas del CronJob (Hit #7). Implementación esperada: deployment de Postgres en el mismo cluster k3s (StatefulSet + PVC + Service), credenciales via Secret, schema migrations (Alembic / Flyway / Liquibase / SQL files versionados). Tabla mínima: (producto, titulo, precio, link, tienda_oficial, scraped_at).

    Schema mínimo (versionarlo en una migration):

    CREATE TABLE IF NOT EXISTS scrape_results (
        id           BIGSERIAL PRIMARY KEY,
        producto     TEXT      NOT NULL,
        titulo       TEXT      NOT NULL,
        precio       NUMERIC(12,2),
        link         TEXT,
        tienda_oficial TEXT,
        envio_gratis BOOLEAN,
        scraped_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );
    CREATE INDEX IF NOT EXISTS idx_scrape_results_producto_fecha
        ON scrape_results (producto, scraped_at DESC);

    Con este schema y un par de corridas del CronJob, una query como:

    SELECT producto, MIN(precio), MAX(precio), AVG(precio), COUNT(DISTINCT scraped_at) AS n_runs
    FROM scrape_results
    WHERE scraped_at > NOW() - INTERVAL '7 days'
    GROUP BY producto;

    les da una vista de la evolución de precios sin escribir código adicional.


Cómo entregar

  1. Push final al repo público antes del 02/05/2026 23:59 ART.
  2. README raíz actualizado con:
    • Sección “Prerrequisitos cumplidos” mostrando evidencia del checklist del TP 0.
    • Cómo correr Parte 1 + Parte 2 (Docker, k3s/k3d).
    • Comandos exactos para reproducir el demo del Hit #7.
  3. Carpeta docs/adr/ con mínimo 4 ADRs (2 elegidos del menú + 2 de su elección).
  4. Video mostrando: Hit #4 corriendo (con JSON resultante), pipeline de CI verde con coverage ≥ 70 %, kubectl apply -f k8s/ con Job completado y CronJob activo.
  5. Mensaje en el canal Discord de la materia con el link al repo y al video.

📡 Canal Discord (consultas + entregas): https://discord.com/channels/1482135908508500148/1482135909456679139 Antes de pedir ayuda con k3s, revisá el TP 0 y la sección de troubleshooting que ya tiene los 4 errores típicos.


Auto-verificación previa a la entrega

Antes de pushear el commit final, corré estos comandos en tu repo. Si algo de esta lista falla, todavía no entregues — vas a perder puntos seguros que se evitan con 2 minutos de checklist.

1) Tests + cobertura ≥ 70 %

# Python
pytest --cov=. --cov-fail-under=70

# Node
npm test -- --coverage --coverageThreshold='{"global":{"lines":70}}'

# Java
mvn verify   # con jacoco-maven-plugin configurado con minimum 0.70

2) Linter + formatter (los mismos que corren en pre-commit)

ruff check . && ruff format --check .          # Python
npx eslint . && npx prettier --check .          # Node
mvn spotless:check && mvn checkstyle:check      # Java

3) Detección de secrets

gitleaks detect --no-git --verbose
# alternativa si ya está configurado pre-commit:
pre-commit run gitleaks --all-files

4) Manifests Kubernetes válidos

for f in k8s/*.yaml; do
  kubectl apply --dry-run=client -f "$f" || echo "❌ $f rompe"
done

5) Build de la imagen Docker

docker build -t ml-scraper:test .
docker run --rm -e HEADLESS=true -e BROWSER=chrome \
  -v $(pwd)/output:/app/output \
  ml-scraper:test --limit 3
# Verificá que se generen los 3 JSON en output/

El reporte indica qué hits encuentra, qué anti-patterns detecta (time.sleep, selectores hardcodeados, secrets, etc.) y un score estimado. No reemplaza la corrección humana, pero detecta los errores estructurales más comunes.

7) E2E completo en cluster local

# Cargá la imagen al cluster (k3s o k3d)
docker save ml-scraper:test -o /tmp/img.tar && sudo k3s ctr images import /tmp/img.tar
# o:  k3d image import ml-scraper:test -c <tu-cluster>

kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/
kubectl wait --for=condition=complete job/scraper-once --timeout=600s -n ml-scraper
kubectl logs -l job-name=scraper-once -n ml-scraper

Si el Job termina en Complete y los logs muestran los 3 JSON generados, están listos para entregar.

8) Verificar que los retries del Hit #5 efectivamente disparan

# Forzar un fallo transitorio (ej: matar la red 1 segundo) y ver el log:
docker compose up scraper 2>&1 | grep -E "retry|backoff|reintento"
# Output esperado: ver al menos 1 línea WARNING con "intento N/3" antes del éxito.

Si tu corrida nunca dispara retries (porque ML responde rápido en tu red), no hace falta forzarlo — pero el código tiene que estar ahí. La cátedra valida con un test unitario en CI que el decorador @with_backoff reintenta correctamente ante TimeoutException mockeada.


Criterios de evaluación — Parte 2

Requisitos bloqueantes (no se acepta la entrega sin estos)

Estos no suman puntos — son condición necesaria para que la entrega sea corregible. Si falta cualquiera de los 4, la nota es 0.

Tabla de puntaje (100 %)

Criterio Peso
Hit #4 — extracción estructurada a JSON de los 3 productos con todos los campos 25 %
Hit #5 — manejo robusto de errores (selectores faltantes, timeouts, retries con backoff) 15 %
Hit #6 — tests automatizados + cobertura ≥ 70 % validada en CI 15 %
Hit #7Job + CronJob + ConfigMap + PVC corriendo en k3s/k3d 20 %
Hit #8 — capacidad extendida (paginación + stats + histórico PostgreSQL en k3s) 15 %
ADRs (mínimo 4 en docs/adr/ — 2 del menú propuesto + 2 de elección propia) 10 %

Material de apoyo

Tabla de herramientas por lenguaje

Para que no pierdan tiempo eligiendo, esto es lo que esperamos en cada stack:

Stack Coverage tool Linter Formatter Pre-commit framework
Python 3.13 coverage.py + pytest-cov (gate: --cov-fail-under=70) ruff check ruff format (o black) pre-commit (pre-commit.com)
Node.js 20 jest --coverage con coverageThreshold ≥ 70 % eslint prettier husky + lint-staged o pre-commit
Java 17 jacoco (<minimum>0.70</minimum> en jacoco-maven-plugin) checkstyle o pmd spotless (google-java-format) pre-commit con hooks Maven

Plantilla de ADR (docs/adr/0000-template.md)

Formato Michael Nygard, 1 página. Copien esto a docs/adr/0000-template.md y úsenlo como base para los 4 ADRs obligatorios (2 del menú + 2 propios).

Referencias:

# 000X — <Título corto, en imperativo>

- **Date:** YYYY-MM-DD
- **Status:** Accepted | Proposed | Deprecated | Superseded by 000Y
- **Deciders:** <integrantes que tomaron la decisión>

## Contexto

¿Qué problema o trade-off estamos enfrentando? ¿Cuáles son las alternativas que consideramos?
2-4 párrafos cortos.

## Decisión

¿Qué decidimos hacer? Una oración clara.

## Consecuencias

- Lo que se vuelve **más fácil** con esta decisión.
- Lo que se vuelve **más difícil** o se sacrifica.
- Riesgos conocidos y cómo se mitigan.

## Referencias

- Links a docs, papers, charlas que informaron la decisión.

Ejemplo concreto de un ADR (0001-framework-automatizacion.md):

# 0001 — Usamos Selenium WebDriver y no Playwright

- Date: 2026-04-15
- Status: Accepted
- Deciders: Juan Pérez, María García

## Contexto

Necesitamos automatizar un browser para scrapear MercadoLibre. Las alternativas son:
- **Selenium** (W3C standard, soporte multi-lenguaje, ecosistema enorme).
- **Playwright** (más rápido, mejor DX, soporta multi-browser nativo).
- **Puppeteer** (Chrome-only, descartado por requisito multi-browser).
- **Cypress** (E2E pero pensado para apps propias, no scraping de terceros).

## Decisión

Usamos **Selenium 4** porque la consigna del TP exige Selenium específicamente como
herramienta de aprendizaje (estándar W3C WebDriver), y porque queremos exposición a la
herramienta más usada en la industria para QA.

## Consecuencias

- Más fácil: comunidad enorme, drivers nativos para Chrome/Firefox/Edge, bindings en Python/Java/JS/Ruby/C#.
- Más difícil: API más verbosa que Playwright, no maneja contextos paralelos automáticamente, debemos manejar waits explícitos manualmente.
- Riesgo: scripts más frágiles ante cambios del DOM. Mitigamos con selectores estables (Hit #5) y reintentos con backoff.

## Referencias

- W3C WebDriver spec: https://www.w3.org/TR/webdriver2/
- Selenium docs: https://www.selenium.dev/documentation/

Esqueleto de logging estructurado (Hit #5)

print() no alcanza para el Hit #5 y no es aceptable como evidencia de robustez: no tiene niveles (no podés filtrar DEBUG vs ERROR en CI), no tiene rotación (en el CronJob de k3s el archivo crece para siempre y termina llenando el PVC), y es ilegible en CI (no hay timestamp, no hay módulo, no se distingue del output del scraper).

Esqueleto Python (poner en un módulo logging_setup.py y llamarlo una vez desde scraper.py):

import logging
from logging.handlers import RotatingFileHandler

def setup_logging(log_file: str = "output/scraper.log") -> None:
    fmt = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
    formatter = logging.Formatter(fmt, datefmt="%Y-%m-%dT%H:%M:%S%z")

    # Rotating file → no crece infinito en el CronJob
    file_handler = RotatingFileHandler(
        log_file, maxBytes=2_000_000, backupCount=3, encoding="utf-8"
    )
    file_handler.setFormatter(formatter)

    # Stream → se ve en `kubectl logs` y en GH Actions
    stream_handler = logging.StreamHandler()
    stream_handler.setFormatter(formatter)

    logging.basicConfig(
        level=logging.INFO,
        handlers=[file_handler, stream_handler],
    )

# En cada módulo (extractors.py, retry.py, etc.):
logger = logging.getLogger(__name__)

# Uso típico:
logger.info("Scrapeando página %d", page)
logger.warning("Precio ausente, devuelvo null", extra={"producto": p})
logger.error("Timeout tras 3 reintentos", exc_info=True)

Equivalentes nombrados:

Stack Librería recomendada Por qué
Node.js pino (o winston) pino es el más rápido y emite JSON nativo (ideal para kubectl logs + parsers). winston es más config-friendly pero más lento.
Java SLF4J + Logback logback.xml con <RollingFileAppender> + <SizeBasedTriggeringPolicy> cumple lo mismo que RotatingFileHandler.

Esqueleto de tests mínimos (Hit #6)

Importante: los tests del Hit #6 deben correr sin abrir un browser real — usen mocks. Eso los hace rápidos (todo el suite < 1s), determinísticos, y CI-friendly (no necesitan Chrome ni display). Si un test del Hit #6 levanta Selenium de verdad, está mal hecho — eso pertenece a un test E2E aparte.

Esqueleto Python con pytest:

# tests/conftest.py
import pytest
from unittest.mock import MagicMock

@pytest.fixture
def mock_element():
    """WebElement falso con .text y .get_attribute() configurables."""
    el = MagicMock()
    el.text = ""
    el.get_attribute.return_value = ""
    return el

@pytest.fixture
def fake_driver():
    return MagicMock()
# tests/test_extractors.py
from extractors import extract_precio
from selenium.common.exceptions import NoSuchElementException

def test_extract_precio_happy_path(mock_element):
    mock_element.text = "$ 12.345"
    assert extract_precio(mock_element) == 12345.0

def test_extract_precio_soft_fail_returns_null(fake_driver):
    fake_driver.find_element.side_effect = NoSuchElementException()
    assert extract_precio(fake_driver) is None  # no levanta, devuelve null
# tests/test_retry.py
from unittest.mock import MagicMock
from selenium.common.exceptions import TimeoutException
from retry import fetch_with_retry

def test_retry_dispara_3_veces_ante_timeout():
    fn = MagicMock(side_effect=[TimeoutException(), TimeoutException(), "ok"])
    assert fetch_with_retry(fn, max_attempts=3) == "ok"
    assert fn.call_count == 3

Correrlos con la gate de cobertura del Hit #6:

pytest --cov=. --cov-fail-under=70

Equivalentes:

Esqueleto del Dockerfile multi-stage (Infra base)

Esto es un punto de partida. Adapten al lenguaje. La idea es multi-stage para que la imagen final sea lo más chica posible (no necesitan compiladores ni headers en runtime).

🔒 Pin de versiones obligatorio. Nunca usen :latest en una imagen base — cada docker build puede traer bytes distintos y rompe la reproducibilidad. Mínimo aceptable: pin a MAJOR.MINOR-<distro> (ej: python:3.13-slim-trixie). Mejor: pin a MAJOR.MINOR.PATCH. Production-grade: pin por digest sha256 (@sha256:...). En 2026 usen las versiones LTS más nuevas: Python 3.13, Node 24 (LTS Oct 2025), Java 25 (LTS Sept 2025).

# ============ Stage 1: builder (deps + compile) ============
FROM python:3.13-slim-trixie AS builder
WORKDIR /app

# System deps para compilar wheels si hace falta
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# ============ Stage 2: runtime (browsers + app) ============
FROM python:3.13-slim-trixie AS runtime
WORKDIR /app

# Instalar Google Chrome stable + Firefox + deps mínimas
# Nota: NO uses `chromium` de Debian trixie (bug de crashpad en headless).
# Usá google-chrome-stable del repo oficial.
RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates curl gnupg \
    firefox-esr \
    fonts-liberation \
    && curl -fsSL https://dl.google.com/linux/linux_signing_key.pub \
       | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
    && echo "deb [signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" \
       > /etc/apt/sources.list.d/google-chrome.list \
    && apt-get update && apt-get install -y --no-install-recommends \
       google-chrome-stable \
    && apt-get purge -y curl gnupg \
    && apt-get autoremove -y \
    && rm -rf /var/lib/apt/lists/*

# Copiar deps de Python desde el builder
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH

# Usuario no-root con HOME (Chrome necesita ~/.local para crashpad)
RUN useradd --create-home --uid 1000 scraper
USER scraper

COPY --chown=scraper:scraper . .

# Healthcheck — opcional pero recomendado
HEALTHCHECK --interval=30s --timeout=5s \
  CMD python -c "import selenium; print('ok')" || exit 1

ENTRYPOINT ["python", "scraper.py"]
CMD ["--browser", "chrome"]

Equivalentes para otros stacks (pinear MAJOR.MINOR + distro como mínimo, usar versiones LTS más nuevas de 2026):

⚠️ User-Agent custom obligatorio en headless. MercadoLibre detecta Chrome/Firefox headless con UA por defecto y devuelve la página sin resultados. En el código del scraper (no en el Dockerfile) hay que setear:

options.add_argument(
  '--user-agent=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 '
  '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
)

Equivalente Java: options.addArguments("--user-agent=..."). Equivalente JS: options.addArguments('--user-agent=...') o setUserAgent() en Puppeteer/Playwright. Sin esto, el pipeline de CI funciona pero los JSON salen vacíos.

Esqueleto del workflow de CI (.github/workflows/scrape.yml, Infra base)

name: Scraper CI

on:
  push: { branches: [main] }
  pull_request: { branches: [main] }
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-24.04
    strategy:
      fail-fast: false
      matrix:
        browser: [chrome, firefox]
    steps:
      - uses: actions/checkout@v4

      - name: Detect secrets with gitleaks
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Build Docker image
        run: docker build -t ml-scraper:ci .

      - name: Run scraper (headless ${{ matrix.browser }})
        run: |
          docker run --rm \
            -e BROWSER=${{ matrix.browser }} \
            -e HEADLESS=true \
            -v ${{ github.workspace }}/output:/app/output \
            ml-scraper:ci

      - name: Run tests with coverage gate
        run: |
          docker run --rm \
            -v ${{ github.workspace }}/coverage:/app/coverage \
            ml-scraper:ci \
            pytest --cov=. --cov-report=html:/app/coverage --cov-fail-under=70

      - name: Upload artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: scraper-output-${{ matrix.browser }}
          path: |
            output/
            coverage/

  validate-k8s:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: azure/setup-kubectl@v4
      - name: Validate Kubernetes manifests (dry-run)
        run: |
          for f in k8s/*.yaml; do
            kubectl apply --dry-run=client -f "$f"
          done

Esqueleto de pre-commit (.pre-commit-config.yaml)

Aplica a Python como ejemplo. Para Node sustituyan los hooks de ruff por eslint/prettier.

repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.8.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files

Activar local: pip install pre-commit && pre-commit install. Documenten el comando en el README.

Equivalente Node.js — husky + lint-staged (package.json):

{
  "scripts": {
    "prepare": "husky"
  },
  "lint-staged": {
    "*.{js,ts}": ["eslint --fix", "prettier --write"],
    "*.{json,md,yaml,yml}": ["prettier --write"]
  },
  "devDependencies": {
    "husky": "^9.1.0",
    "lint-staged": "^15.2.0",
    "eslint": "^9.0.0",
    "prettier": "^3.3.0"
  }
}

Y .husky/pre-commit:

#!/usr/bin/env sh
npx lint-staged
gitleaks detect --no-git --redact --verbose

Activar local: npm install && npm run prepare (husky deja el hook listo).

Equivalente Java — Maven + Spotless (pom.xml, fragmento del <build>):

<plugin>
  <groupId>com.diffplug.spotless</groupId>
  <artifactId>spotless-maven-plugin</artifactId>
  <version>2.43.0</version>
  <configuration>
    <java>
      <googleJavaFormat><version>1.22.0</version></googleJavaFormat>
      <removeUnusedImports/>
      <trimTrailingWhitespace/>
    </java>
  </configuration>
  <executions>
    <execution>
      <goals><goal>check</goal></goals>
      <phase>verify</phase>
    </execution>
  </executions>
</plugin>

Y un hook Git (.git/hooks/pre-commit, o un perfil prepare-commit en Maven) que invoque:

mvn spotless:check && gitleaks detect --no-git --redact

En cualquier stack se puede usar el framework pre-commit.com directamente — es agnóstico al lenguaje y soporta hooks de Node, Java, Go, Rust, etc. Si no quieren mantener husky o pom.xml + hook custom, pre-commit es la opción más portable.


Referencias y Bibliografía

Solo lo directamente vinculado a lo que se les pide en Parte 2. Los libros generales de Kubernetes / containers viven en el TP 0 y no se repiten.

Infra base — Dockerfile + docker-compose + CI/CD + pre-commit

Hit #4 — Extracción JSON estructurada

Hit #5 — Robustez, retries y logging

Hit #6 — Tests automatizados + cobertura

Hit #7 — Kubernetes Job + CronJob + ConfigMap + PVC

Hit #8 — Paginación + estadísticas + PostgreSQL

ADRs

Dataset / sitio del TP