Scraping

Ejecución de scrapers en CI/CD: credenciales de proxy, secretos y ejecuciones de prueba

Cómo ejecutar scrapers en CI sin filtrar credenciales de proxy ni consumir ancho de banda en cada push: almacenamiento de secretos, niveles de prueba, sesiones, comprobaciones de cuota y rotación.

Chris Collins

Chris Collins

21 de septiembre de 2026 · 12 min de lectura

Un scraper que funciona en un portátil suele encontrarse con la CI de una de dos maneras. O alguien pega la contraseña del proxy en el archivo de workflow “solo para que se ponga en verde”, o el pipeline ejecuta un scrape en vivo completo en cada push y se gasta silenciosamente un mes de ancho de banda para el miércoles. Ambas cosas se pueden evitar, y solucionarlas consiste principalmente en decidir, de antemano, qué ejecuciones necesitan realmente un proxy en vivo.

Este tutorial cubre dónde deben vivir las credenciales de proxy en CI, cómo mantenerlas fuera de los logs, cómo dividir los tests para que la mayoría de las ejecuciones nunca toquen la red, y cómo rotar una credencial sin sobresaltos. Los ejemplos usan GitHub Actions y GitLab CI con el gateway residencial de Shifter, pero la estructura se traslada a cualquier runner.

Qué puede salir mal

Cuatro modos de fallo explican casi todos los incidentes de credenciales en CI con scrapers:

FalloCómo ocurre
Credencial commiteadaUn archivo .env o una URL de proxy hardcodeada acaba en el repositorio
Credencial en logsUna línea de depuración imprime la URL del proxy, o un cliente HTTP registra las cabeceras de las peticiones
Credencial expuesta a código no confiableUn pull request de alguien externo al equipo se ejecuta con los secretos disponibles
Ancho de banda desperdiciadoCada push ejecuta un scrape en vivo contra sitios reales

Los tres primeros son problemas de seguridad. El cuarto es un problema de coste que además hace que el pipeline sea inestable, porque los sitios web reales cambian y tu build no debería fallar cuando falla la página de otro.

Paso 1: guardar la credencial como secreto, nunca en el código

Tu usuario y contraseña residencial están en la página del plan en el panel. Guárdalos como dos secretos de CI:

  • SHIFTER_USERNAME: el nombre de usuario completo tal como se muestra, por ejemplo customer-USERNAME
  • SHIFTER_PASSWORD: la contraseña

GitHub Actions. Añade ambos en Settings del repositorio, Secrets and variables, Actions. GitHub redacta los valores de los secretos en los logs, y, con la excepción de GITHUB_TOKEN, los secretos no se pasan al runner cuando un workflow se dispara desde un repositorio bifurcado (fork). Esa segunda regla importa para repositorios públicos: el pull request de un colaborador externo no puede leer tu contraseña de proxy, pero tampoco puede ejecutar tus tests en vivo, lo cual gestionarás en el paso 3.

GitLab CI. Añade ambos como variables CI/CD, márcalos como masked y márcalos como protected para que estén disponibles solo en pipelines de ramas o tags protegidas. Hay un detalle lo suficientemente específico como para mencionarlo: GitLab solo puede enmascarar un valor que sea una sola línea de 8 caracteres o más. Las contraseñas residenciales de Shifter pueden ser más cortas que eso, y GitLab se negará a enmascararlas. La solución es guardar el par como una única variable enmascarada:

SHIFTER_PROXY_AUTH=customer-USERNAME:PASSWORD

Ese valor supera cómodamente los 8 caracteres y usa solo caracteres que GitLab permite en variables enmascaradas. Divídelo por el último dos puntos en tu código.

Añade .env al .gitignore en el mismo commit, para que un archivo local nunca siga a la credencial hasta el repositorio.

Paso 2: construir la URL del proxy en el código, y nunca imprimirla

Ensambla la URL del proxy en tiempo de ejecución a partir del entorno, en un solo lugar, para que el targeting y las flags de sesión se añadan de forma consistente y nada más maneje la contraseña en bruto:

import os

GATEWAY = "p.shifter.io:443"


def shifter_credentials():
    if "SHIFTER_PROXY_AUTH" in os.environ:
        username, password = os.environ["SHIFTER_PROXY_AUTH"].rsplit(":", 1)
        return username, password
    return os.environ["SHIFTER_USERNAME"], os.environ["SHIFTER_PASSWORD"]


def proxy_url(country=None, session=None, ttl=None):
    username, password = shifter_credentials()
    if country:
        username += f"-country-{country}"
    if session:
        username += f"-sid-{session}"
        if ttl:
            username += f"-ttl-{ttl}"
    return f"http://{username}:{password}@{GATEWAY}"


def redact(url):
    # Safe to log: keeps the flags, drops the password.
    creds, host = url.rsplit("@", 1)
    return f"{creds.rsplit(':', 1)[0]}:***@{host}"

Registra redact(url) si necesitas ver qué flags usó una ejecución. Nunca registres la URL en sí.

El enmascarado de secretos tiene un punto ciego que vale la pena conocer. Coincide con el valor almacenado, así que un valor transformado se le escapa. La autenticación del proxy se envía como una cabecera Proxy-Authorization que contiene la codificación base64 de username:password, y un log de depuración de cabeceras de petición imprime esa codificación, que ningún enmascarador de CI reconocerá. Mantén desactivado el logging de depuración a nivel de cabeceras en CI. Si tienes que ensamblar una cadena sensible que no es en sí misma un secreto, regístrala con el comando ::add-mask:: de GitHub antes de que nada pueda imprimirla.

Pasa los secretos a tu scraper como variables de entorno, no como argumentos de línea de comandos. La propia guía de GitHub recomienda evitar pasar secretos entre procesos por la línea de comandos siempre que sea posible. Los argumentos son fáciles de ver en los listados de procesos y tienden a acabar en trazas de shell.

Paso 3: dividir los tests para que la mayoría de las ejecuciones nunca necesiten un proxy

Este paso elimina la mayor parte del coste y de la inestabilidad. Divide los tests del scraper en tres niveles:

NivelQué compruebaNecesita proxyCuándo se ejecuta
Tests de parserLógica de extracción contra fixtures de HTML guardadosNoCada push y pull request
Test de humo en vivoUn puñado de peticiones reales a través del gatewayRama principal y una programación
Ejecución completaEl scrape realSu propia programación, o un disparador manual

Los tests de parser son la mayor parte de tu cobertura. Guarda respuestas reales como archivos fixture, y comprueba que tus selectores extraen los campos correctos de ellos. Se ejecutan en segundos, no cuestan nada, no necesitan secretos, y solo fallan cuando tu código está mal. Cuando un sitio cambia su diseño, guarda un fixture nuevo y actualiza el parser en el mismo pull request.

El test de humo en vivo confirma que las credenciales, el targeting y la conectividad funcionan, no que cada página se parsea. Mantenlo a unas pocas peticiones. Debería saltarse, en lugar de fallar, cuando el secreto no está presente, que es exactamente la situación en un pull request de un fork:

import os
import pytest
import requests

from scraper.proxy import proxy_url

live = pytest.mark.skipif(
    not (os.environ.get("SHIFTER_PASSWORD") or os.environ.get("SHIFTER_PROXY_AUTH")),
    reason="no proxy credentials in this environment",
)


@live
def test_gateway_exits_in_requested_country():
    url = proxy_url(country="de")
    r = requests.get("https://ipinfo.io/json", proxies={"http": url, "https": url}, timeout=30)
    assert r.status_code == 200
    assert r.json()["country"] == "DE"

La ejecución completa pertenece a una programación, no a un push, para que un merge no dispare un scrape del tamaño de producción.

Paso 4: una sesión por job, nunca compartida

Las sesiones persistentes (sticky) fijan una ejecución a una única IP de salida, que es lo que quieres para flujos de varios pasos como la paginación. El id de sesión de Shifter es cualquier cadena que elijas, con una duración por defecto de 120 segundos que ttl sobrescribe. La advertencia de la documentación aplica directamente a la CI: no reutilices un id de sesión entre workflows concurrentes, porque las peticiones de distintos jobs que caen en la misma IP se leen como sospechosas para la mayoría de sistemas anti-bot.

La CI ya te da un valor único por ejecución. Úsalo, más el índice del job si ejecutas una matriz:

import os

run = os.environ.get("GITHUB_RUN_ID") or os.environ.get("CI_PIPELINE_ID", "local")
job = os.environ.get("JOB_INDEX", "0")
url = proxy_url(country="us", session=f"ci{run}j{job}", ttl=600)

Mantén el id alfanumérico, ya que el nombre de usuario usa guiones para separar las flags. Para peticiones que son independientes entre sí, omite la sesión por completo y cada petición rota a una IP nueva.

Paso 5: comprobar la cuota antes de una ejecución grande

Un scrape programado que se queda sin ancho de banda a mitad de camino es peor que uno que nunca empezó. La API de uso y cuota de Shifter devuelve lo que queda en un plan, así que un paso de comprobación previa puede saltar la ejecución de forma limpia:

import os
import sys
import requests

MIN_GB = float(os.environ.get("MIN_REMAINING_GB", "5"))

r = requests.get(
    f"https://shifter.io/api/v1/memberships/{os.environ['SHIFTER_MEMBERSHIP']}/usage",
    params={"api_token": os.environ["SHIFTER_API_TOKEN"]},
    timeout=30,
)
r.raise_for_status()
plan = r.json()["data"]

if plan["metered"] and plan["remaining_gb"] < MIN_GB:
    print(f"Only {plan['remaining_gb']} GB left, resets {plan['resets_at']}. Skipping run.")
    sys.exit(78)

El token de la API se genera en el panel, en Account, API Tokens. Guárdalo como secreto igual que la contraseña: pertenece a tu cuenta más que a un workspace, así que lee el uso de todos los workspaces de los que eres miembro. El endpoint está limitado a 60 peticiones por minuto, muchas más de las que necesita una comprobación previa.

Si una ejecución agota el plan con Extra Traffic desactivado, el gateway devuelve 509 Bandwidth Limit Exceeded. Trata eso como una condición de parada, no como algo a reintentar.

Paso 6: fallar rápido ante errores de autenticación

Los reintentos son adecuados para errores de red transitorios e inadecuados para errores de credenciales. Un 407 Proxy Authentication Required significa que el usuario, la contraseña o una flag están mal, y seguirá igual de mal en el siguiente intento. En CI, un bucle de reintentos alrededor de un 407 convierte un fallo de un segundo en uno de diez minutos con un log confuso. Haz que tu cliente trate el 407 como fatal, imprima la URL del proxy redactada, y se detenga. Las causas habituales, y el orden en que comprobarlas, se cubren en solucionar errores 407 de autenticación de proxy.

Un workflow completo de GitHub Actions

name: scraper

on:
  push:
  pull_request:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:

jobs:
  parser-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: pytest tests/parsers

  smoke-test:
    if: github.ref == 'refs/heads/main'
    needs: parser-tests
    runs-on: ubuntu-latest
    env:
      SHIFTER_USERNAME: ${{ secrets.SHIFTER_USERNAME }}
      SHIFTER_PASSWORD: ${{ secrets.SHIFTER_PASSWORD }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: pytest tests/live

  full-run:
    if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
    needs: smoke-test
    runs-on: ubuntu-latest
    concurrency: scraper-full-run
    env:
      SHIFTER_USERNAME: ${{ secrets.SHIFTER_USERNAME }}
      SHIFTER_PASSWORD: ${{ secrets.SHIFTER_PASSWORD }}
      SHIFTER_API_TOKEN: ${{ secrets.SHIFTER_API_TOKEN }}
      SHIFTER_MEMBERSHIP: ${{ vars.SHIFTER_MEMBERSHIP }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: python scripts/check_quota.py
      - run: python -m scraper.run

Los tests de parser se ejecutan en todas partes sin secretos. El test de humo y la ejecución completa solo existen donde hay secretos. El grupo concurrency evita que dos ejecuciones programadas se solapen y compartan un presupuesto de IP. El ID del membership no es secreto, así que vive en una variable de repositorio normal.

Vale la pena hacer un ajuste: tal como está escrito, el código de salida 78 del script de cuota hace fallar el job. Si prefieres ver una ejecución saltada en lugar de una en rojo, dale al paso de comprobación un id, haz que escriba una flag en $GITHUB_OUTPUT, y condiciona el paso de scrape a esa salida.

Rotar la credencial

Rota según una programación, e inmediatamente siempre que un secreto pueda haberse filtrado: un log público, un colaborador que se marcha, un workflow bifurcado del que no estás seguro.

En Shifter, el propietario de la cuenta o un Admin del workspace pueden generar una nueva contraseña residencial desde la página del plan en el panel. Los miembros Viewer y Billing no pueden. El gateway adopta la nueva contraseña de inmediato, y la antigua deja de funcionar en el mismo momento, así que planifica el orden:

  1. Pausa el workflow programado, o acepta que una ejecución en curso fallará con un 407.
  2. Genera la nueva contraseña en el panel.
  3. Actualiza el secreto de CI inmediatamente.
  4. Actualiza cualquier otro consumidor del mismo plan. La contraseña pertenece al plan, no a un pipeline, así que cualquier otra cosa que la use se rompe en el mismo momento.
  5. Vuelve a ejecutar el test de humo para confirmarlo.

El punto 4 es la razón por la que conviene saber dónde se usa la credencial de un plan antes de necesitar rotarla. Si varios pipelines independientes comparten un plan, una única rotación afecta a todos ellos.

En resumen

Buena parte del trabajo de ejecutar scrapers desde CI consiste en mantener el proxy fuera de las ejecuciones que no lo necesitan. Los tests de parser contra fixtures cubren la lógica en cada push, sin secretos y sin ancho de banda. Un pequeño test de humo en vivo en main comprueba que las credenciales siguen funcionando. El scrape real se ejecuta según una programación, comprueba primero su cuota, usa un id de sesión que nadie más comparte, y se detiene al primer error de autenticación en lugar de reintentarlo.

La credencial en sí vive en el almacén de secretos de la CI, se ensambla en una única función, y nunca se imprime, ni siquiera en base64. Para recolecciones de más larga duración, el lado operativo de vigilar un pipeline se cubre en monitorización de un pipeline de web scraping, y repartir uno entre regiones en failover de proxies residenciales para pipelines multi-región. La configuración de proxy del lado del cliente para scrapers basados en navegador está en configurar proxies residenciales en Selenium y Playwright.

¿Listo para empezar?

Prueba los proxies residenciales de Shifter, más de 205M IPs, más de 195 países, desde 0,75 $/GB.

Comenzar