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:
| Fallo | Cómo ocurre |
|---|---|
| Credencial commiteada | Un archivo .env o una URL de proxy hardcodeada acaba en el repositorio |
| Credencial en logs | Una 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 confiable | Un pull request de alguien externo al equipo se ejecuta con los secretos disponibles |
| Ancho de banda desperdiciado | Cada 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 ejemplocustomer-USERNAMESHIFTER_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:
| Nivel | Qué comprueba | Necesita proxy | Cuándo se ejecuta |
|---|---|---|---|
| Tests de parser | Lógica de extracción contra fixtures de HTML guardados | No | Cada push y pull request |
| Test de humo en vivo | Un puñado de peticiones reales a través del gateway | Sí | Rama principal y una programación |
| Ejecución completa | El scrape real | Sí | Su 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:
- Pausa el workflow programado, o acepta que una ejecución en curso fallará con un 407.
- Genera la nueva contraseña en el panel.
- Actualiza el secreto de CI inmediatamente.
- 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.
- 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.