Scraping

Exécution des scrapers en CI/CD : identifiants proxy, secrets et exécutions de tests

Comment exécuter des scrapers en CI sans divulguer les identifiants proxy ni gaspiller de la bande passante à chaque push : stockage des secrets, niveaux de test, sessions, vérifications de quota et rotation.

Chris Collins

Chris Collins

21 septembre 2026 · 12 min de lecture

Un scraper qui fonctionne sur un ordinateur portable rencontre généralement la CI de l’une de ces deux façons. Soit quelqu’un colle le mot de passe du proxy dans le fichier de workflow “juste pour que ça passe au vert”, soit le pipeline exécute un scraping complet en direct à chaque push et dépense discrètement un mois de bande passante avant même mercredi. Les deux sont évitables, et les corriger revient surtout à décider, en amont, quelles exécutions ont réellement besoin d’un proxy en direct.

Ce tutoriel couvre où les identifiants de proxy doivent vivre en CI, comment les garder hors des logs, comment répartir les tests pour que la plupart des exécutions ne touchent jamais le réseau, et comment faire tourner un identifiant sans précipitation. Les exemples utilisent GitHub Actions et GitLab CI avec la passerelle résidentielle de Shifter, mais la structure s’applique à n’importe quel runner.

Ce qui peut mal se passer

Quatre modes de défaillance représentent presque tous les incidents d’identifiants CI avec des scrapers :

DéfaillanceComment cela arrive
Identifiant commitéUn fichier .env ou une URL de proxy codée en dur atterrit dans le dépôt
Identifiant dans les logsUne ligne de debug affiche l’URL du proxy, ou un client HTTP journalise les en-têtes de requête
Identifiant exposé à du code non fiableUne pull request venant de l’extérieur de l’équipe s’exécute avec les secrets disponibles
Bande passante gaspilléeChaque push exécute un scraping en direct contre de vrais sites

Les trois premiers sont des problèmes de sécurité. Le quatrième est un problème de coût qui rend aussi le pipeline instable, car les sites web réels changent et votre build ne devrait pas échouer parce que la page de quelqu’un d’autre a échoué.

Étape 1 : stocker l’identifiant comme un secret, jamais dans le code

Votre nom d’utilisateur et mot de passe résidentiels se trouvent sur la page du plan dans le panel. Stockez-les comme deux secrets CI :

  • SHIFTER_USERNAME : le nom d’utilisateur complet tel qu’affiché, par exemple customer-USERNAME
  • SHIFTER_PASSWORD : le mot de passe

GitHub Actions. Ajoutez les deux sous les paramètres du dépôt, Secrets and variables, Actions. GitHub masque les valeurs secrètes dans les logs, et, à l’exception de GITHUB_TOKEN, les secrets ne sont pas transmis au runner lorsqu’un workflow est déclenché depuis un dépôt forké. Cette seconde règle compte pour les dépôts publics : la pull request d’un contributeur externe ne peut pas lire votre mot de passe de proxy, mais elle ne peut pas non plus exécuter vos tests en direct, ce que vous traiterez à l’étape 3.

GitLab CI. Ajoutez les deux comme variables CI/CD, marquez-les masked, et marquez-les protected afin qu’elles ne soient disponibles que pour les pipelines sur des branches ou tags protégés. Un piège assez spécifique mérite d’être signalé : GitLab ne peut masquer qu’une valeur qui est une seule ligne de 8 caractères ou plus. Les mots de passe résidentiels de Shifter peuvent être plus courts que cela, et GitLab refusera de les masquer. La solution consiste à stocker la paire comme une seule variable masquée :

SHIFTER_PROXY_AUTH=customer-USERNAME:PASSWORD

Cette valeur dépasse confortablement 8 caractères et n’utilise que des caractères que GitLab autorise dans les variables masquées. Découpez-la sur le dernier deux-points dans votre code.

Ajoutez .env à .gitignore dans le même commit, afin qu’un fichier local ne suive jamais l’identifiant dans le dépôt.

Étape 2 : construire l’URL du proxy dans le code, et ne jamais l’afficher

Assemblez l’URL du proxy au moment de l’exécution à partir de l’environnement, en un seul endroit, afin que le ciblage et les indicateurs de session soient ajoutés de manière cohérente et que rien d’autre ne manipule jamais le mot de passe brut :

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):
    # Sûr à journaliser : conserve les indicateurs, supprime le mot de passe.
    creds, host = url.rsplit("@", 1)
    return f"{creds.rsplit(':', 1)[0]}:***@{host}"

Journalisez redact(url) si vous avez besoin de voir quels indicateurs une exécution a utilisés. Ne journalisez jamais l’URL elle-même.

Le masquage des secrets a un angle mort qu’il vaut la peine de connaître. Il correspond à la valeur stockée, donc une valeur transformée passe à travers. L’authentification proxy est envoyée comme un en-tête Proxy-Authorization contenant l’encodage base64 de username:password, et un log de debug des en-têtes de requête affiche cet encodage, qu’aucun masqueur de CI ne reconnaîtra. Gardez la journalisation de debug au niveau des en-têtes désactivée en CI. Si vous devez assembler une chaîne sensible qui n’est pas elle-même un secret, enregistrez-la avec la commande ::add-mask:: de GitHub avant que quoi que ce soit puisse l’afficher.

Transmettez les secrets à votre scraper comme variables d’environnement, pas comme arguments de ligne de commande. Les propres recommandations de GitHub sont d’éviter de transmettre des secrets entre processus sur la ligne de commande lorsque c’est possible. Les arguments sont faciles à voir dans les listes de processus et ont tendance à finir dans les traces shell.

Étape 3 : répartir les tests pour que la plupart des exécutions n’aient jamais besoin d’un proxy

Cette étape supprime la majeure partie du coût et de l’instabilité. Divisez les tests du scraper en trois niveaux :

NiveauCe qu’il vérifieNécessite un proxyQuand il s’exécute
Tests du parseurLogique d’extraction contre des fixtures HTML sauvegardéesNonÀ chaque push et pull request
Test de fumée en directUne poignée de vraies requêtes via la passerelleOuiBranche principale et un planning
Exécution complèteLe scraping réelOuiSon propre planning, ou un déclenchement manuel

Les tests du parseur constituent le gros de votre couverture. Sauvegardez de vraies réponses comme fichiers de fixtures, et testez que vos sélecteurs extraient les bons champs de ces fichiers. Ils s’exécutent en quelques secondes, ne coûtent rien, ne nécessitent aucun secret, et n’échouent que lorsque votre code est fautif. Quand un site change sa mise en page, sauvegardez une fixture fraîche et mettez à jour le parseur dans la même pull request.

Le test de fumée en direct confirme que les identifiants, le ciblage et la connectivité fonctionnent, pas que chaque page s’analyse. Limitez-le à quelques requêtes. Il devrait être ignoré, plutôt qu’échouer, quand le secret est absent, ce qui est exactement la situation sur une pull request de 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"

L’exécution complète appartient à un planning, pas au push, afin qu’une fusion ne déclenche pas un scraping de taille production.

Étape 4 : une session par job, jamais partagée

Les sessions persistantes (sticky) épinglent une exécution à une seule IP de sortie, ce qui est ce que vous voulez pour des flux à plusieurs étapes comme la pagination. L’identifiant de session de Shifter est n’importe quelle chaîne de votre choix, avec une durée de vie par défaut de 120 secondes que ttl remplace. L’avertissement de la documentation s’applique directement à la CI : ne réutilisez pas un identifiant de session à travers des workflows concurrents, car des requêtes provenant de jobs différents et atterrissant sur la même IP paraissent suspectes à la plupart des systèmes anti-bot.

La CI vous fournit déjà une valeur unique par exécution. Utilisez-la, plus l’index du job si vous exécutez une matrice :

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)

Gardez l’identifiant alphanumérique, car le nom d’utilisateur utilise des tirets pour séparer les indicateurs. Pour des requêtes indépendantes les unes des autres, omettez complètement la session et chaque requête tourne vers une IP fraîche.

Étape 5 : vérifier le quota avant une grosse exécution

Un scraping planifié qui manque de bande passante à mi-chemin est pire qu’un qui n’a jamais démarré. L’API Usage and Quota de Shifter renvoie ce qui reste sur un plan, afin qu’une étape de préflight puisse annuler l’exécution proprement :

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)

Le jeton API est généré dans le panel sous Account, API Tokens. Stockez-le comme secret comme le mot de passe : il appartient à votre compte plutôt qu’à un espace de travail, donc il lit l’usage pour chaque espace de travail dont vous êtes membre. Le point de terminaison est limité à 60 requêtes par minute, bien plus que ce dont un préflight a besoin.

Si une exécution épuise effectivement le plan avec Extra Traffic désactivé, la passerelle renvoie 509 Bandwidth Limit Exceeded. Traitez cela comme une condition d’arrêt, pas comme quelque chose à retenter.

Étape 6 : échouer rapidement sur les erreurs d’authentification

Les nouvelles tentatives sont appropriées pour les erreurs réseau transitoires et inappropriées pour les erreurs d’identifiants. Un 407 Proxy Authentication Required signifie que le nom d’utilisateur, le mot de passe ou un indicateur est incorrect, et ce sera tout aussi incorrect à la tentative suivante. En CI, une boucle de nouvelles tentatives autour d’un 407 transforme un échec d’une seconde en un échec de dix minutes avec un log confus. Faites en sorte que votre client traite le 407 comme fatal, affiche l’URL de proxy expurgée, et s’arrête. Les causes habituelles, et l’ordre dans lequel les vérifier, sont couvertes dans corriger les erreurs d’authentification proxy 407.

Un workflow GitHub Actions complet

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

Les tests du parseur s’exécutent partout sans aucun secret. Le test de fumée et l’exécution complète n’existent que là où des secrets existent. Le groupe concurrency empêche deux exécutions planifiées de se superposer et de partager un budget d’IP. L’ID de l’espace de travail n’est pas secret, donc il vit dans une simple variable de dépôt.

Un ajustement mérite d’être fait : tel qu’écrit, le code de sortie 78 du script de quota fait échouer le job. Si vous préférez voir une exécution ignorée plutôt qu’une exécution rouge, donnez à l’étape de vérification un id, faites-lui écrire un indicateur dans $GITHUB_OUTPUT, et conditionnez l’étape de scraping sur cette sortie.

Faire tourner l’identifiant

Faites tourner l’identifiant selon un planning, et immédiatement chaque fois qu’un secret pourrait avoir fuité : un log public, un contractant qui part, un workflow forké dont vous n’êtes pas sûr.

Sur Shifter, le propriétaire du compte ou un Admin d’espace de travail peut générer un nouveau mot de passe résidentiel depuis la page du plan dans le panel. Les membres Viewer et Billing ne le peuvent pas. La passerelle prend en compte le nouveau mot de passe immédiatement, et l’ancien cesse de fonctionner au même moment, donc planifiez l’ordre :

  1. Mettez en pause le workflow planifié, ou acceptez qu’une exécution en cours échouera avec un 407.
  2. Générez le nouveau mot de passe dans le panel.
  3. Mettez à jour le secret CI immédiatement.
  4. Mettez à jour tout autre consommateur du même plan. Le mot de passe appartient au plan, pas à un pipeline, donc tout autre système l’utilisant se casse au même moment.
  5. Relancez le test de fumée pour confirmer.

Le point 4 est la raison pour laquelle il vaut mieux savoir où l’identifiant d’un plan est utilisé avant d’en avoir besoin de le faire tourner. Si plusieurs pipelines indépendants partagent un même plan, une seule rotation les touche tous.

L’essentiel à retenir

La majeure partie du travail dans l’exécution de scrapers depuis la CI consiste à garder le proxy hors des exécutions qui n’en ont pas besoin. Les tests du parseur contre des fixtures couvrent la logique à chaque push, sans secrets et sans bande passante. Un petit test de fumée en direct sur la branche principale prouve que les identifiants fonctionnent toujours. Le vrai scraping s’exécute selon un planning, vérifie son quota d’abord, utilise un identifiant de session que personne d’autre ne partage, et s’arrête à la première erreur d’authentification au lieu de la retenter.

L’identifiant lui-même vit dans le magasin de secrets CI, s’assemble dans une seule fonction, et ne s’affiche jamais, y compris en base64. Pour une collecte de plus longue durée, l’aspect opérationnel de la surveillance d’un pipeline est couvert dans surveiller un pipeline de web scraping, et la répartition d’un pipeline entre régions dans basculement de proxy résidentiel pour pipelines multi-régions. La configuration côté client des proxys pour les scrapers basés sur navigateur se trouve dans configurer des proxys résidentiels dans Selenium et Playwright.

Prêt à commencer ?

Essayez les proxies résidentiels de Shifter, 205M+ IPs, 195+ pays, à partir de 0,75 $/GB.

Commencer