> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kameleoon.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Exporter les résultats d'expériences vers Airtable

> Récupérez une expérience et ses résultats avec l'Automation API, déterminez la variation la plus performante à partir de la probabilité de succès bayésienne, et upsertez l'enregistrement dans une table Airtable à l'aide d'un seul script Python.

## Objectif

Ce tutoriel décrit, étape par étape, le fonctionnement du script [kameleoon\_to\_airtable.py](https://storage.googleapis.com/kameleoon-storage-documentation/developers/scripts/kameleoon_to_airtable.py). À partir d'un **ID d'expérience** Kameleoon, d'un **ID de base** Airtable et d'un **ID de table** Airtable, le script récupère les métadonnées de l'expérience et ses résultats statistiques, détermine la variation la plus performante, mappe les données sur le schéma Airtable *Experiments*, et écrit l'enregistrement dans Airtable. Réexécuter le script pour la même expérience met à jour la ligne existante au lieu d'en créer un doublon.

Ce tutoriel fait suite au tutoriel précédent sur la [récupération des résultats d'expériences avec l'Automation API](./retrieving-experiment-results-using-the-automation-api) et réutilise le même flux de demande et d'interrogation.

## Prérequis

* **Identifiants API Kameleoon.** L'Automation API nécessite un access token. Le script en obtient un de manière programmatique à partir d'un `client_id` et d'un `client_secret` grâce au grant `client_credentials`. Consultez [Obtenir un access token](/fr/developer-docs/apis/automation-api-rest/get-started/get-started#1-obtain-an-access-token).

* **Un personal access token Airtable** avec le scope `data.records:write` sur la base cible.

* **L'ID de base et l'ID de table Airtable.** Les deux apparaissent dans l'URL de la table, ou dans la documentation API de la base. L'ID de base commence par `app`, l'ID de table par `tbl`.

* **Une table Airtable avec le schéma *Experiments* déjà construit.** Le script écrit dans les champs suivants : `Experiment Name`, `Status`, `Start date`, `End date`, `Notes`, `Actual`, `Probability`, et `Result`. Il s'attend aussi à ce que les champs à saisie manuelle `Assignee`, `Category`, `Prediction`, `Mkt Est`, `Eng Est`, et `Attachments` existent, même s'il ne les définit jamais. Airtable rejette une écriture sur un nom de champ qui n'existe pas déjà dans la table, et `typecast` ne fait que convertir le type des valeurs pour les champs existants (il ne crée ni champs manquants ni options de sélection). Créez la table avec ces champs, et les options de sélection `Status` correspondantes, avant d'exécuter le script. Consultez [l'étape 6](#6-mapper-les-donnees-aux-champs-airtable) pour connaître la valeur reçue par chaque champ.

* **Python 3.9 ou supérieur** avec la bibliothèque `requests` (`pip install requests`).

<Warning>
  Stockez tous les identifiants dans des variables d'environnement. N'écrivez jamais les secrets en dur dans le script.
</Warning>

```bash theme={null}
export KAMELEOON_CLIENT_ID="..."
export KAMELEOON_CLIENT_SECRET="..."
export AIRTABLE_TOKEN="..."
```

Le tutoriel utilise l'expérience d'exemple **Product Page Redesign** (ID `188308`), avec deux variations en plus de l'originale : *Redesign 1* (ID `828220`) et *Redesign 2* (ID `828221`).

## 1. S'authentifier auprès de l'Automation API

**Endpoint :** obtenez un access token en envoyant une requête POST à l'endpoint de token.

```
POST https://api.kameleoon.com/oauth/token
```

| Champ          | Type   | Description                                |
| -------------- | ------ | ------------------------------------------ |
| grant\_type    | String | À définir sur `client_credentials`.        |
| client\_id     | String | Votre client ID pour l'Automation API.     |
| client\_secret | String | Votre client secret pour l'Automation API. |

**Exemple :**

```python theme={null}
def kameleoon_token(client_id, client_secret):
    resp = requests.post(
        "https://api.kameleoon.com/oauth/token",
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        data={
            "grant_type": "client_credentials",
            "client_id": client_id,
            "client_secret": client_secret,
        },
    )
    resp.raise_for_status()
    return resp.json()["access_token"]
```

**Réponse :**

```json theme={null}
{ "access_token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9..." }
```

Envoyez l'`access_token` retourné en tant que token `Bearer` pour chaque requête suivante à l'Automation API. Les access tokens restent valides 2 heures par défaut.

## 2. Récupérer l'expérience

**Endpoint :** récupérez les métadonnées de l'expérience en envoyant une requête GET à l'endpoint [Get an experiment](/api-reference/experiment/get-an-experiment).

```
GET https://api.kameleoon.com/experiments/{experimentId}
```

| Champ        | Type    | Description                                                        |
| ------------ | ------- | ------------------------------------------------------------------ |
| experimentId | Integer | Paramètre de chemin obligatoire. L'ID de l'expérience à récupérer. |

**Exemple :**

```python theme={null}
def get_experiment(token, experiment_id):
    resp = requests.get(
        f"https://api.kameleoon.com/experiments/{experiment_id}",
        headers={"Authorization": f"Bearer {token}"},
    )
    resp.raise_for_status()
    return resp.json()
```

**Réponse (abrégée) :**

```json theme={null}
{
  "id": 188308,
  "name": "Product Page Redesign",
  "status": "STOPPED",
  "dateStarted": "2025-01-15T09:00:00Z",
  "dateEnded": "2025-02-12T18:00:00Z",
  "description": "Testing two redesigns of the product page.",
  "mainGoalId": 279599,
  "variations": [828220, 828221]
}
```

Le script lit `name`, `status`, `dateStarted`, `dateEnded`, et `description` pour l'enregistrement Airtable, ainsi que `mainGoalId` pour restreindre la requête de résultats à l'étape suivante.

<Note>
  L'API retourne `mainGoalId` par défaut ; vous n'avez donc pas besoin d'un paramètre `optionalFields` pour le lire. L'Automation API ne publie pas d'énumération fixe pour le champ `status`, mais les autres champs de type statut de l'API utilisent systématiquement des tokens en majuscules (par exemple, `STOPPED`, `ACTIVE`, `DRAFT`). [L'étape 6](#6-mapper-les-donnees-aux-champs-airtable) mappe le champ `status` sur cette hypothèse. Confirmez les tokens exacts que votre compte retourne avec une requête réelle avant de vous fier à ce mapping.
</Note>

## 3. Demander les résultats de l'expérience

**Endpoint :** déclenchez la génération du rapport de résultats en envoyant une requête POST à l'endpoint [Request experiment's results](/api-reference/experiment/request-experiments-results).

```
POST https://api.kameleoon.com/experiments/{experimentId}/results
```

| Champ                | Type    | Description                                                                                                                                               |
| -------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| experimentId         | Integer | Paramètre de chemin obligatoire.                                                                                                                          |
| goalsIds             | Array   | Restreint le rapport aux ID d'objectifs donnés. Le script transmet le `mainGoalId` de l'expérience.                                                       |
| referenceVariationId | String  | La variation utilisée comme référence de comparaison. `"0"` utilise la page originale.                                                                    |
| visitorData          | Boolean | `false` pour des données par visite, `true` pour des données par visiteur.                                                                                |
| sequentialTesting    | Boolean | À définir sur `true` pour utiliser le sequential testing pour les intervalles de confiance.                                                               |
| bayesian             | Boolean | À définir sur `true` pour inclure la probabilité de succès bayésienne dans le rapport. Le script lit cette valeur pour renseigner le champ *Probability*. |
| conversionType       | String  | `ALL_CONVERSION` ou `CONVERTED_VISITS`.                                                                                                                   |

**Exemple :**

```python theme={null}
def request_results(token, experiment_id, goal_id):
    body = {
        "visitorData": False,
        "sequentialTesting": True,
        "bayesian": True,
        "referenceVariationId": "0",
        "conversionType": "ALL_CONVERSION",
        "goalsIds": [goal_id] if goal_id else None,
    }
    resp = requests.post(
        f"https://api.kameleoon.com/experiments/{experiment_id}/results",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Accept": "*/*",
        },
        json=body,
    )
    resp.raise_for_status()
    return resp.json()["dataCode"]
```

**Réponse :**

```json theme={null}
{ "dataCode": "14443931880924098266207585267983330260134079899081739889989435434342588016765" }
```

Kameleoon génère le rapport de manière asynchrone. L'endpoint retourne un `dataCode`, que [l'étape 4](#4-interroger-les-resultats) utilise pour interroger le résultat.

<Note>
  Ce script nécessite `bayesian: true`. Avec Bayesian activé, la valeur `reliability` du rapport porte la **probabilité de succès bayésienne** (la probabilité qu'une variation batte la référence), que le script mappe sur le champ *Probability*. Confirmez cette valeur par rapport au même rapport dans l'application Kameleoon si votre compte utilise une méthode statistique par défaut différente. La spécification de l'Automation API marque `dateIntervals` comme obligatoire, mais l'exemple ci-dessus l'omet et retourne malgré tout un rapport valide. La spécification ne documente pas la valeur par défaut appliquée quand `dateIntervals` est omis ; ce tutoriel suppose qu'elle couvre l'intégralité de la durée de l'expérience, donc confirmez ce comportement sur votre propre compte avant de vous y fier. Transmettez plutôt un tableau `dateIntervals` pour limiter le rapport à une période spécifique.
</Note>

## 4. Interroger les résultats

**Endpoint :** récupérez le rapport en envoyant des requêtes GET à l'endpoint [Poll results](/api-reference/data/poll-results) jusqu'à ce qu'il soit prêt.

```
GET https://api.kameleoon.com/results?dataCode={dataCode}
```

| Champ    | Type   | Description                                                                                                             |
| -------- | ------ | ----------------------------------------------------------------------------------------------------------------------- |
| dataCode | String | Paramètre de requête obligatoire. Utilisez le hash retourné par `POST /experiments/{experimentId}/results` à l'étape 3. |

Le `status` de la réponse est `WAITING` pendant que Kameleoon calcule le rapport, `READY` quand les données sont disponibles, ou `ERROR` / `TIMEOUT` en cas d'échec. Quand le statut est `ERROR` ou `TIMEOUT`, la réponse inclut un `errorDescription` de premier niveau. Le script interroge à intervalle fixe jusqu'à ce que le statut soit `READY`.

**Exemple :**

```python theme={null}
def poll_results(token, data_code, max_attempts=30, delay=2.0):
    for _ in range(max_attempts):
        resp = requests.get(
            "https://api.kameleoon.com/results",
            headers={"Authorization": f"Bearer {token}"},
            params={"dataCode": data_code},
        )
        resp.raise_for_status()
        payload = resp.json()
        status = payload.get("status")
        if status == "READY":
            return payload["data"]
        if status in ("ERROR", "TIMEOUT"):
            raise RuntimeError(payload.get("errorDescription") or status)
        time.sleep(delay)  # status == "WAITING"
    raise TimeoutError("Timed out waiting for results.")
```

**Réponse (abrégée) :**

```json theme={null}
{
  "status": "READY",
  "data": {
    "variationData": {
      "_reference": { "breakdownData": { "_reference": { "generalData": {
        "goalsData": { "279599": { "conversionRate": 0.0149 } } } } } },
      "828220": { "breakdownData": { "_reference": { "generalData": {
        "goalsData": { "279599": {
          "reliability": 100.0,
          "improvementRate": 211.48,
          "conversionRate": 0.0466
        } } } } } },
      "828221": { "breakdownData": { "_reference": { "generalData": {
        "goalsData": { "279599": {
          "reliability": 100.0,
          "improvementRate": -43.33,
          "conversionRate": 0.0085
        } } } } } }
    }
  }
}
```

## 5. Sélectionner la variation la plus performante

Les résultats contiennent une entrée par variation sous `variationData`, ainsi que la ligne `_reference` pour la page originale. Pour chaque variation, les métriques de l'objectif demandé se trouvent sous `breakdownData._reference.generalData.goalsData[goalId]`.

Le script ignore l'entrée `_reference`, lit l'`improvementRate` et la `reliability` (la probabilité de succès bayésienne) de chaque variation, et sélectionne comme meilleure performance la variation ayant le taux d'amélioration le plus élevé. Le champ *Result* mappé à l'étape suivante indique si cette variation a réellement gagné : si elle a atteint une probabilité de succès suffisamment élevée avec un uplift positif.

**Exemple :**

```python theme={null}
def pick_best_variation(result_data, goal_id):
    best, best_improvement = {}, float("-inf")
    for variation_id, vdata in result_data["variationData"].items():
        if variation_id == "_reference":
            continue
        general = vdata["breakdownData"]["_reference"]["generalData"]
        goals_data = general.get("goalsData", {})
        if not goals_data:
            continue
        key = str(goal_id) if str(goal_id) in goals_data else next(iter(goals_data))
        metrics = goals_data[key]
        improvement = metrics.get("improvementRate")
        if improvement is not None and improvement > best_improvement:
            best_improvement = improvement
            best = {
                "variation_id": variation_id,
                "improvement_rate": improvement,
                "bayesian_probability": metrics.get("reliability"),
            }
    return best
```

Dans l'exemple, les deux variations atteignent une probabilité de succès bayésienne de 100 %, mais *Redesign 1* (`828220`) affiche une amélioration de +211,48 % contre -43,33 % pour *Redesign 2*. *Redesign 1* est donc la variation la plus performante et, avec une probabilité supérieure à 95 % et un uplift positif, une véritable gagnante.

## 6. Mapper les données aux champs Airtable

Le script transforme les métadonnées de l'expérience et les métriques de la variation la plus performante en schéma Airtable *Experiments*.

| Champ Airtable  | Source                                                     | Transformation                                                                                                                                 |
| --------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Experiment Name | `experiment.name`                                          | Direct. Utilisé comme clé d'upsert.                                                                                                            |
| Status          | `experiment.status`                                        | Mappé, sans distinction de casse : `ACTIVE → Running`, `DRAFT`/`PLANNED → Implementing`, `STOPPED`/`DIVERTED → Completed`, `PAUSED → Defunct`. |
| Start date      | `experiment.dateStarted`                                   | Date-heure tronquée en date ISO (`YYYY-MM-DD`).                                                                                                |
| End date        | `experiment.dateEnded`                                     | Date-heure tronquée en date ISO.                                                                                                               |
| Notes           | `experiment.description`                                   | Direct.                                                                                                                                        |
| Actual          | `improvementRate` de la meilleure variation                | Direct (uplift mesuré, %).                                                                                                                     |
| Probability     | probabilité de succès bayésienne de la meilleure variation | Répartie en buckets : `≥95 → 80% - High`, `≥80 → 50% - Medium`, sinon `20% - Low`.                                                             |
| Result          | probabilité de succès bayésienne + `improvementRate`       | probabilité `≥ 95` et uplift > 0 → `Success` ; probabilité `≥ 95` et uplift \< 0 → `Failure` ; sinon `Inconclusive`.                           |

Le script ne définit pas les champs sans source Kameleoon : **Assignee**, **Category**, **Prediction**, **Mkt Est**, **Eng Est**, et **Attachments**. Ces champs restent disponibles pour une saisie manuelle dans Airtable. Le script omet aussi les valeurs vides, il n'écrase donc jamais une cellule existante avec une valeur vide.

**Exemple :**

```python theme={null}
STATUS_MAP = {
    "ACTIVE": "Running",
    "DRAFT": "Implementing",
    "PLANNED": "Implementing",
    "PAUSED": "Defunct",
    "STOPPED": "Completed",
    "DIVERTED": "Completed",
}


def map_status(status):
    return STATUS_MAP.get((status or "").upper())


def to_iso_date(value):
    return value[:10] if value else None


def bayesian_to_probability(probability):
    if probability is None:
        return None
    if probability >= 95:
        return "80% - High"
    if probability >= 80:
        return "50% - Medium"
    return "20% - Low"


def derive_result(probability, improvement):
    if probability is None or improvement is None:
        return "Inconclusive"
    if probability >= 95 and improvement > 0:
        return "Success"
    if probability >= 95 and improvement < 0:
        return "Failure"
    return "Inconclusive"


def build_airtable_fields(experiment, best):
    probability = best.get("bayesian_probability")
    improvement = best.get("improvement_rate")
    fields = {
        "Experiment Name": experiment.get("name"),
        "Status": map_status(experiment.get("status")),
        "Start date": to_iso_date(experiment.get("dateStarted")),
        "End date": to_iso_date(experiment.get("dateEnded")),
        "Notes": experiment.get("description"),
        "Actual": improvement,
        "Probability": bayesian_to_probability(probability),
        "Result": derive_result(probability, improvement),
    }
    return {k: v for k, v in fields.items() if v is not None}
```

<Note>
  L'Automation API ne publie pas d'énumération fixe pour le champ `status` de l'expérience, et les tokens peuvent évoluer. `map_status` effectue une correspondance sans distinction de casse et retourne `None` pour un statut non reconnu, ce qui supprime la cellule *Status* au lieu d'écrire une valeur incorrecte. Confirmez les tokens que votre compte retourne avec un simple `GET /experiments/{experimentId}` et étendez `STATUS_MAP` si nécessaire.
</Note>

## 7. Upserter l'enregistrement dans Airtable

<Note>
  L'endpoint Airtable *Update table* (`PATCH /v0/meta/bases/{baseId}/tables/{tableId}`) modifie uniquement le nom et la description d'une table ; il ne peut pas écrire de données dans les lignes. Pour renseigner un enregistrement, utilisez l'endpoint **records** avec l'option `performUpsert`.
</Note>

**Endpoint :** créez ou mettez à jour l'enregistrement en envoyant une requête PATCH à l'endpoint records.

```
PATCH https://api.airtable.com/v0/{baseId}/{tableId}
```

| Champ                         | Type    | Description                                                                                                                 |
| ----------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------- |
| performUpsert.fieldsToMergeOn | Array   | Champ(s) utilisé(s) pour faire correspondre un enregistrement existant. Le script effectue la fusion sur `Experiment Name`. |
| records                       | Array   | Une liste d'enregistrements (10 maximum par requête), chacun avec un objet `fields`.                                        |
| typecast                      | Boolean | `true` permet à Airtable de convertir les chaînes en options de sélection et d'analyser les dates.                          |

**Exemple :**

```python theme={null}
def upsert_record(airtable_token, base_id, table_id, fields):
    resp = requests.patch(
        f"https://api.airtable.com/v0/{base_id}/{table_id}",
        headers={
            "Authorization": f"Bearer {airtable_token}",
            "Content-Type": "application/json",
        },
        json={
            "performUpsert": {"fieldsToMergeOn": ["Experiment Name"]},
            "typecast": True,
            "records": [{"fields": fields}],
        },
    )
    resp.raise_for_status()
    return resp.json()
```

**Réponse (abrégée) :**

```json theme={null}
{
  "records": [
    {
      "id": "rec0Vh8KLmNoPqRsT",
      "fields": {
        "Experiment Name": "Product Page Redesign",
        "Status": "Completed",
        "Start date": "2025-01-15",
        "End date": "2025-02-12",
        "Actual": 211.48,
        "Probability": "80% - High",
        "Result": "Success"
      }
    }
  ],
  "createdRecords": [],
  "updatedRecords": ["rec0Vh8KLmNoPqRsT"]
}
```

La réponse indique le résultat pour chaque enregistrement : un ID retourné sous `createdRecords` signifie qu'Airtable a créé une nouvelle ligne, tandis qu'un ID sous `updatedRecords` signifie qu'Airtable a mis à jour une ligne existante.

## 8. Exécuter le script

Transmettez l'ID de l'expérience ainsi que les ID de base et de table Airtable en arguments :

```bash theme={null}
python kameleoon_to_airtable.py \
  --experiment-id 188308 \
  --base-id appXXXXXXXXXXXXXX \
  --table-id tbll9adSjdedH5t3f
```

Le script affiche chaque étape : l'authentification, l'expérience récupérée, la variation la plus performante, les champs mappés, et s'il a créé ou mis à jour l'enregistrement Airtable.

## Script complet

Le script complet ci-dessous correspond fonction par fonction à [kameleoon\_to\_airtable.py](https://storage.googleapis.com/kameleoon-storage-documentation/developers/scripts/kameleoon_to_airtable.py). Copiez-le directement, ou téléchargez le fichier depuis ce lien.

```python theme={null}
#!/usr/bin/env python3
"""Export a Kameleoon experiment's results to an Airtable *Experiments* table.

Given a Kameleoon experiment ID, an Airtable base ID, and an Airtable table ID,
this script:

  1. Authenticates with the Automation API (client_credentials grant).
  2. Retrieves the experiment metadata.
  3. Requests the experiment's results (with the Bayesian success probability).
  4. Polls until the report is ready.
  5. Selects the best-performing variation.
  6. Maps the data onto the Airtable *Experiments* schema.
  7. Upserts the record into Airtable, keyed on "Experiment Name".

Credentials are read from environment variables (never hard-code secrets):

    export KAMELEOON_CLIENT_ID="..."
    export KAMELEOON_CLIENT_SECRET="..."
    export AIRTABLE_TOKEN="..."

Usage:

    python kameleoon_to_airtable.py \
        --experiment-id 188308 \
        --base-id appXXXXXXXXXXXXXX \
        --table-id tbll9adSjdedH5t3f
"""

import argparse
import os
import sys
import time

import requests

# Maps the uppercase status tokens returned by the Automation API to the
# options of the Airtable *Status* field. Adjust the target values if your
# *Status* options differ, and confirm the tokens your account returns with a
# single GET /experiments/{experimentId}.
STATUS_MAP = {
    "ACTIVE": "Running",
    "DRAFT": "Implementing",
    "PLANNED": "Implementing",
    "PAUSED": "Defunct",
    "STOPPED": "Completed",
    "DIVERTED": "Completed",
}


# --------------------------------------------------------------------------- #
# 1. Authenticate with the Automation API
# --------------------------------------------------------------------------- #
def kameleoon_token(client_id, client_secret):
    resp = requests.post(
        "https://api.kameleoon.com/oauth/token",
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        data={
            "grant_type": "client_credentials",
            "client_id": client_id,
            "client_secret": client_secret,
        },
    )
    resp.raise_for_status()
    return resp.json()["access_token"]


# --------------------------------------------------------------------------- #
# 2. Retrieve the experiment
# --------------------------------------------------------------------------- #
def get_experiment(token, experiment_id):
    resp = requests.get(
        f"https://api.kameleoon.com/experiments/{experiment_id}",
        headers={"Authorization": f"Bearer {token}"},
    )
    resp.raise_for_status()
    return resp.json()


# --------------------------------------------------------------------------- #
# 3. Request the experiment's results
# --------------------------------------------------------------------------- #
def request_results(token, experiment_id, goal_id):
    body = {
        "visitorData": False,
        "sequentialTesting": True,
        "bayesian": True,
        "referenceVariationId": "0",
        "conversionType": "ALL_CONVERSION",
        "goalsIds": [goal_id] if goal_id else None,
    }
    resp = requests.post(
        f"https://api.kameleoon.com/experiments/{experiment_id}/results",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Accept": "*/*",
        },
        json=body,
    )
    resp.raise_for_status()
    return resp.json()["dataCode"]


# --------------------------------------------------------------------------- #
# 4. Poll for the results
# --------------------------------------------------------------------------- #
def poll_results(token, data_code, max_attempts=30, delay=2.0):
    for _ in range(max_attempts):
        resp = requests.get(
            "https://api.kameleoon.com/results",
            headers={"Authorization": f"Bearer {token}"},
            params={"dataCode": data_code},
        )
        resp.raise_for_status()
        payload = resp.json()
        status = payload.get("status")
        if status == "READY":
            return payload["data"]
        if status in ("ERROR", "TIMEOUT"):
            raise RuntimeError(payload.get("errorDescription") or status)
        time.sleep(delay)  # status == "WAITING"
    raise TimeoutError("Timed out waiting for results.")


# --------------------------------------------------------------------------- #
# 5. Select the best-performing variation
# --------------------------------------------------------------------------- #
def pick_best_variation(result_data, goal_id):
    best, best_improvement = {}, float("-inf")
    for variation_id, vdata in result_data["variationData"].items():
        if variation_id == "_reference":
            continue
        general = vdata["breakdownData"]["_reference"]["generalData"]
        goals_data = general.get("goalsData", {})
        if not goals_data:
            continue
        key = str(goal_id) if str(goal_id) in goals_data else next(iter(goals_data))
        metrics = goals_data[key]
        improvement = metrics.get("improvementRate")
        if improvement is not None and improvement > best_improvement:
            best_improvement = improvement
            best = {
                "variation_id": variation_id,
                "improvement_rate": improvement,
                "bayesian_probability": metrics.get("reliability"),
            }
    return best


# --------------------------------------------------------------------------- #
# 6. Map the data to Airtable fields
# --------------------------------------------------------------------------- #
def map_status(status):
    return STATUS_MAP.get((status or "").upper())


def to_iso_date(value):
    return value[:10] if value else None


def bayesian_to_probability(probability):
    if probability is None:
        return None
    if probability >= 95:
        return "80% - High"
    if probability >= 80:
        return "50% - Medium"
    return "20% - Low"


def derive_result(probability, improvement):
    if probability is None or improvement is None:
        return "Inconclusive"
    if probability >= 95 and improvement > 0:
        return "Success"
    if probability >= 95 and improvement < 0:
        return "Failure"
    return "Inconclusive"


def build_airtable_fields(experiment, best):
    probability = best.get("bayesian_probability")
    improvement = best.get("improvement_rate")
    fields = {
        "Experiment Name": experiment.get("name"),
        "Status": map_status(experiment.get("status")),
        "Start date": to_iso_date(experiment.get("dateStarted")),
        "End date": to_iso_date(experiment.get("dateEnded")),
        "Notes": experiment.get("description"),
        "Actual": improvement,
        "Probability": bayesian_to_probability(probability),
        "Result": derive_result(probability, improvement),
    }
    return {k: v for k, v in fields.items() if v is not None}


# --------------------------------------------------------------------------- #
# 7. Upsert the record into Airtable
# --------------------------------------------------------------------------- #
def upsert_record(airtable_token, base_id, table_id, fields):
    resp = requests.patch(
        f"https://api.airtable.com/v0/{base_id}/{table_id}",
        headers={
            "Authorization": f"Bearer {airtable_token}",
            "Content-Type": "application/json",
        },
        json={
            "performUpsert": {"fieldsToMergeOn": ["Experiment Name"]},
            "typecast": True,
            "records": [{"fields": fields}],
        },
    )
    resp.raise_for_status()
    return resp.json()


# --------------------------------------------------------------------------- #
# 8. Orchestration
# --------------------------------------------------------------------------- #
def run(experiment_id, base_id, table_id):
    client_id = os.environ.get("KAMELEOON_CLIENT_ID")
    client_secret = os.environ.get("KAMELEOON_CLIENT_SECRET")
    airtable_token = os.environ.get("AIRTABLE_TOKEN")

    missing = [
        name
        for name, value in (
            ("KAMELEOON_CLIENT_ID", client_id),
            ("KAMELEOON_CLIENT_SECRET", client_secret),
            ("AIRTABLE_TOKEN", airtable_token),
        )
        if not value
    ]
    if missing:
        raise SystemExit(
            "Missing required environment variable(s): " + ", ".join(missing)
        )

    # 1. Authenticate.
    token = kameleoon_token(client_id, client_secret)
    print("Authenticated with the Automation API.")

    # 2. Retrieve the experiment.
    experiment = get_experiment(token, experiment_id)
    goal_id = experiment.get("mainGoalId")
    print(
        f"Fetched experiment {experiment.get('id')}: "
        f"{experiment.get('name')!r} (status: {experiment.get('status')})."
    )

    # 3-4. Request and poll for the results.
    data_code = request_results(token, experiment_id, goal_id)
    result_data = poll_results(token, data_code)

    # 5. Select the best-performing variation.
    best = pick_best_variation(result_data, goal_id)
    if best:
        print(
            f"Best-performing variation: {best['variation_id']} "
            f"(uplift {best['improvement_rate']}%, "
            f"Bayesian probability {best.get('bayesian_probability')}%)."
        )
    else:
        print("No variation data available for the requested goal.")

    # 6. Map the data to Airtable fields.
    fields = build_airtable_fields(experiment, best)
    print("Mapped Airtable fields:")
    for key, value in fields.items():
        print(f"  {key}: {value}")

    # 7. Upsert the record.
    response = upsert_record(airtable_token, base_id, table_id, fields)
    created = response.get("createdRecords") or []
    record_id = response["records"][0]["id"] if response.get("records") else None
    action = "Created" if record_id in created else "Updated"
    print(f"{action} Airtable record {record_id}.")

    return response


def main():
    parser = argparse.ArgumentParser(
        description="Export a Kameleoon experiment's results to Airtable."
    )
    parser.add_argument(
        "--experiment-id",
        required=True,
        help="ID of the Kameleoon experiment to export.",
    )
    parser.add_argument(
        "--base-id",
        required=True,
        help="Airtable base ID (starts with 'app').",
    )
    parser.add_argument(
        "--table-id",
        required=True,
        help="Airtable table ID (starts with 'tbl') or table name.",
    )
    args = parser.parse_args()

    try:
        run(args.experiment_id, args.base_id, args.table_id)
    except requests.HTTPError as exc:
        detail = ""
        if exc.response is not None:
            detail = f" — {exc.response.status_code}: {exc.response.text}"
        print(f"HTTP error: {exc}{detail}", file=sys.stderr)
        sys.exit(1)
    except (RuntimeError, TimeoutError) as exc:
        print(f"Error: {exc}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
```

## Notes de personnalisation

* **Le mapping de statut** se trouve dans la constante `STATUS_MAP`, indexée sur les tokens de statut en majuscules retournés par l'API. Ajustez les valeurs cibles si vos options *Status* diffèrent de `Running` / `Implementing` / `Completed` / `Defunct`, et confirmez les tokens que votre compte retourne avec une requête `GET /experiments/{experimentId}` réelle avant de vous fier au mapping.
* **Probability** provient de la probabilité de succès bayésienne mesurée, ce qui nécessite `bayesian: true` sur la requête de résultats. Si votre champ *Probability* est plutôt une estimation pré-expérience que vous saisissez manuellement, supprimez la ligne `Probability` de `build_airtable_fields`.
* **La sélection de l'objectif** utilise le `mainGoalId` de l'expérience. Pour établir un rapport sur un objectif différent, transmettez son ID à `request_results` et à `pick_best_variation`.
* **Clé d'upsert.** Airtable fait correspondre le champ de fusion de manière exacte, donc des différences de casse ou d'espacement dans `Experiment Name` créent une nouvelle ligne au lieu de mettre à jour celle existante. Gardez des noms d'expérience stables, ou effectuez la fusion sur un champ identifiant dédié et stable.
* **Format du champ Actual.** Le script écrit la valeur brute d'`improvementRate` (par exemple, `211.48`) dans *Actual*. Si *Actual* est un champ Airtable de type Percent, configurez-le pour qu'il attende un nombre simple plutôt qu'une fraction, ou divisez la valeur par 100 dans `build_airtable_fields` pour correspondre à un champ Percent basé sur une fraction.
* **Limites de débit.** L'Automation API autorise jusqu'à 50 requêtes par 10 secondes et 1 000 par heure, mais Kameleoon recommande de rester sous 12 appels par minute et par compte, et déconseille l'utilisation de l'Automation API pour du tracking à haute fréquence. Si vous traitez de nombreuses expériences en lot, mettez les tokens en cache, limitez le débit des requêtes, et envisagez la [Data API](/fr/developer-docs/apis/data-api-rest/overview) pour les besoins à fort volume.
