> ## 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 Confluence

> 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 page Confluence, à l'aide d'un script Python ou d'un assistant IA connecté via MCP.

## Objectif

Ce tutoriel décrit, étape par étape, le fonctionnement du script [kameleoon\_to\_confluence.py](/assets/developer-docs/script/apis/automation-api-rest/tutorials/kameleoon_to_confluence.py.zip). À partir d'un **ID d'expérience** Kameleoon, d'un **site** Confluence et d'un **ID d'espace** Confluence, le script récupère les métadonnées de l'expérience et ses résultats statistiques, identifie la variation gagnante, et upserte une ligne pour cette expérience dans un tableau sur une page Confluence. Réexécuter le script pour la même expérience met à jour sa ligne existante au lieu d'en créer un doublon.

Les étapes 1–5 réutilisent le même flux de requête et d'interrogation que le [tutoriel d'export vers Airtable](./exporting-experiment-results-to-airtable). Seule la destination change.

## Alternative : exporter avec un assistant IA au lieu du script

L'exécution du script nécessite un environnement Python, quatre identifiants stockés, et une réexécution manuelle pour chaque expérience à exporter. Si vous utilisez déjà un assistant IA compatible MCP tel que Claude, vous pouvez effectuer le même export depuis une conversation, en le connectant aux serveurs MCP (Model Context Protocol) distants de Kameleoon et d'Atlassian, soit via un outil de codage, soit directement dans l'application Claude. Suivez notre [guide](/fr/mcp/mcp-confluence-guide) pour exporter les résultats à l'aide de Claude.

<Warning>
  Le serveur MCP Atlassian expose des outils sur Jira, Confluence et Bitbucket, y compris l'accès en écriture aux pages Confluence. Le serveur MCP Kameleoon peut aussi démarrer, mettre en pause, arrêter ou supprimer des expériences et des feature flags. Examinez toute action que l'un ou l'autre connecteur propose avant de l'approuver.
</Warning>

## 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-obtenir-un-token-d'accès).

* **Un token API Confluence**, associé à l'adresse e-mail du compte auquel il appartient. Créez un token sur [id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens). Le script envoie les deux en authentification HTTP Basic à chaque requête Confluence.

* **L'ID numérique d'espace Confluence** pour l'espace qui héberge la page. Contrairement à un ID de base de données Notion ou un ID de base Airtable, l'ID numérique d'un espace n'apparaît pas dans son URL (cette URL montre la clé d'espace à la place), et n'apparaît nulle part dans l'interface web Confluence non plus. Demandez à un assistant IA connecté via MCP de le rechercher pour vous, ou récupérez-le vous-même via l'endpoint REST de Confluence [Get spaces](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/#api-spaces-get).

* **Python 3.9+** avec la bibliothèque `requests` (`pip install requests`).

Confluence n'a pas l'équivalent d'une base de données Notion ou d'une table Airtable que vous construisez à l'avance. Le script crée lui-même la page de destination, avec son tableau, à la première exécution. À chaque exécution ultérieure, il trouve cette page par titre et la met à jour.

<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 CONFLUENCE_EMAIL="..."
export CONFLUENCE_API_TOKEN="..."
```

Le tutoriel utilise l'expérience d'exemple **Product Page Redesign** (ID `188308`), avec deux variations en plus de l'originale : variation `828220` et variation `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 la ligne Confluence, et `mainGoalId` pour limiter 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-données-aux-colonnes-confluence) 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, à la place de la probabilité de succès bayésienne. Activez une méthode ou l'autre, pas les deux. |
| 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 la colonne *Probability*.                                  |
| conversionType       | String  | `ALL_CONVERSION` ou `CONVERTED_VISITS`.                                                                                                                                                      |

**Exemple :**

```python theme={null}
def request_results(token, experiment_id, goal_id):
    body = {
        "visitorData": False,
        "sequentialTesting": False,
        "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-résultats) utilise pour interroger le résultat.

<Note>
  Ce script nécessite `bayesian: true` et définit `sequentialTesting: false`. `bayesian` et `sequentialTesting` sont des méthodes alternatives de calcul de la significativité, et ce tutoriel rapporte la probabilité de succès bayésienne. 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 la colonne *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.
</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é. La colonne *Result* mappée à 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 cet exemple, les deux variations atteignent une probabilité de succès bayésienne de 100 %, mais la variation `828220` affiche une amélioration de +211,48 % contre -43,33 % pour la variation `828221`. La variation `828220` a donc le taux d'amélioration le plus élevé et, avec une probabilité supérieure à 95 % et un uplift positif, est la véritable gagnante.

## 6. Mapper les données aux colonnes Confluence

Le script transforme les métadonnées de l'expérience et les métriques de la variation la plus performante en une ligne pour le tableau Confluence, utilisant les mêmes huit colonnes que les exports Notion et Airtable.

| Colonne Confluence | 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             | uplift de la variation gagnante                           | Direct (uplift mesuré, %).                                                                                                                     |
| Probability        | probabilité de succès bayésienne de la variation gagnante | Répartie en buckets : `≥95 → 80% - High`, `≥80 → 50% - Medium`, sinon `20% - Low`.                                                             |
| Result             | probabilité de succès bayésienne + uplift                 | probabilité `≥ 95` et uplift > 0 → `Success` ; probabilité `≥ 95` et uplift \< 0 → `Failure` ; sinon `Inconclusive`.                           |

**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_row(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 [fields.get(column) for column in HEADER]
```

<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 laisse la cellule *Status* vide 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. Analyser le tableau Experiments existant

Confluence n'a pas d'objet base de données ou tableau propre. Au lieu de cela, le script maintient une page dédiée avec un seul tableau HTML, une ligne par expérience, et traite la colonne *Experiment Name* de la même manière que Notion traite une propriété title ou Airtable traite un champ de fusion : comme la clé sur laquelle il upsertez. Le script construit ce tableau avec une disposition `full-width` plutôt que la disposition par défaut plus étroite de Confluence, puisque huit colonnes d'en-têtes modérément longs s'enroulent au milieu du mot à la largeur de page par défaut.

Le format de stockage de Confluence (le balisage de type HTML dans lequel le corps d'une page est stocké) enveloppe le texte de chaque cellule dans une balise `<p>`, et l'en-tête d'une page nouvellement créée est du simple `<tr><th>...</th></tr>` sans `<thead>` environnant. L'analyseur ci-dessous, construit sur la classe standard `html.parser.HTMLParser` de Python, traite à la fois le cas d'en-tête nu et le cas `<thead>`-enveloppé, et traite une cellule vide de la même manière que Confluence la rend, soit comme un `<p></p>` vide soit comme un `<p />` auto-fermant. Il s'arrête aussi au premier `</table>`, donc un second tableau ailleurs sur la page (un que vous avez ajouté à la main, par exemple) ne fusionne jamais dans le résultat analysé, et ferme toute ligne ou cellule qu'une édition malformée a laissée ouverte, qu'la balise suivante ouvre une nouvelle ligne ou que le tableau lui-même se termine, donc une balise non fermée égarée ne peut pas silencieusement supprimer une ligne.

**Exemple :**

```python theme={null}
from html.parser import HTMLParser


class _TableParser(HTMLParser):
    """Extrait la ligne d'en-tête et les lignes de données du premier <table>
    dans un document au format de stockage Confluence. Confluence enveloppe
    le texte des cellules dans des balises <p> et n'émet pas toujours <thead> ;
    une ligne compte comme en-tête la première fois qu'elle est composée de
    cellules <th>. Seul le premier tableau est lu, donc une page avec plus
    d'un tableau (par exemple, un qu'une personne a ajouté à la main) ne
    fusionne jamais la ligne d'en-tête ou les lignes d'un second tableau dans
    le résultat."""

    def __init__(self):
        super().__init__()
        self.header = None
        self.rows = []
        self._row = None
        self._cell = None
        self._row_is_header = False
        self._in_table = False
        self._table_done = False

    def _close_cell(self):
        if self._cell is not None:
            self._row.append("".join(self._cell).strip())
            self._cell = None

    def _close_row(self):
        if self._row is not None:
            self._close_cell()
            if self.header is None and self._row_is_header:
                self.header = self._row
            else:
                self.rows.append(self._row)
            self._row = None

    def handle_starttag(self, tag, attrs):
        if self._table_done:
            return
        if tag == "table" and not self._in_table:
            self._in_table = True
        elif tag == "tr" and self._in_table:
            self._close_row()  # close a still-open row left by an unclosed <tr>
            self._row = []
            self._row_is_header = False
        elif tag in ("td", "th") and self._row is not None:
            self._close_cell()  # close a still-open cell left by an unclosed <td>/<th>
            self._cell = []
            if tag == "th":
                self._row_is_header = True

    def handle_data(self, data):
        if self._cell is not None:
            self._cell.append(data)

    def handle_endtag(self, tag):
        if self._table_done:
            return
        if tag in ("td", "th"):
            self._close_cell()
        elif tag == "tr":
            self._close_row()
        elif tag == "table":
            self._close_row()  # close a still-open row/cell left by an unclosed final <tr>
            self._in_table = False
            self._table_done = True


def parse_experiments_table(storage_value):
    parser = _TableParser()
    parser.feed(storage_value or "")
    return parser.header or HEADER, parser.rows


def reorder_row(source_header, row):
    """Réaligne une ligne analysée par rapport à source_header dans l'ordre HEADER
    canonique, de sorte qu'un tableau dont les colonnes ont été manuellement
    réorganisées dans Confluence ne désaligne pas une recherche basée sur la
    position de colonnes comme la clé Experiment Name. Réaligne seulement une
    véritable réorganisation (les mêmes huit colonnes, différent ordre) ; un
    en-tête avec une colonne renommée ou non reconnue remonte à l'ordre de
    colonnes existant et avertit plutôt que de deviner."""
    if source_header == HEADER:
        return row
    if sorted(source_header) == sorted(HEADER):
        values = dict(zip(source_header, row))
        return [values.get(column) for column in HEADER]
    print(
        f"Warning: table header {source_header} doesn't match the expected "
        f"columns {HEADER}; keeping existing values by position.",
        file=sys.stderr,
    )
    return row


def upsert_row(source_header, rows, key_column, new_row):
    rows = [reorder_row(source_header, row) for row in rows]
    key_index = HEADER.index(key_column)
    key = new_row[key_index]
    for i, row in enumerate(rows):
        if row[key_index] == key:
            rows[i] = new_row
            return rows
    rows.append(new_row)
    return rows
```

<Note>
  `reorder_row` se protège contre un tableau dont les colonnes ont été manuellement réorganisées dans Confluence, par exemple par une personne faisant glisser une colonne dans l'éditeur. Sans cela, une recherche par position comparer le *Experiment Name* de la nouvelle ligne contre quelle que colonne siège maintenant à cette position sur le tableau existant, correspondant silencieusement à la mauvaise ligne ou à aucune. Il réaligne seulement une véritable réorganisation, où l'en-tête a toujours les mêmes huit libellés dans un ordre différent. Si le texte d'une cellule d'en-tête a lui-même été modifié, par exemple en retitrant *Notes* en *Comments*, les libellés ne correspondent plus du tout à `HEADER`, donc la fonction remonte à l'ordre de colonnes existant et imprime un avertissement plutôt que de deviner, plutôt que de silencieusement effacer les données de cette colonne sur chaque ligne.
</Note>

## 8. Trouver, créer ou mettre à jour la page Confluence

**Trouver :** cherchez la page par titre dans l'espace en utilisant les paramètres de requête `title` et `space-id` sur l'endpoint [Get pages](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/#api-pages-get). Passer `body-format=storage` retourne le contenu actuel du tableau dans le même appel.

```
GET https://{site}/wiki/api/v2/pages
```

**Créer :** si aucune page ne correspond, créez-en une avec l'endpoint [Create page](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/#api-pages-post), le tableau déjà construit à partir d'une seule ligne.

```
POST https://{site}/wiki/api/v2/pages
```

**Mettre à jour :** si une page correspond, reconstruisez le tableau entier à partir de ses lignes existantes plus celle upsertée, et écrasez la page avec l'endpoint [Update page](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/#api-pages-id-put).

```
PUT https://{site}/wiki/api/v2/pages/{id}
```

<Note>
  Confluence n'a pas d'endpoint de mise à jour par ligne ou par champ. Mettre à jour une page remplace son corps entier, donc le script lit toujours le tableau actuel, upsertez une ligne en mémoire, et réécrit le tableau entier. Cette remise en place entière du corps diffère de l'export Airtable, qui patchs un seul enregistrement, et de l'export Notion, qui patchs les propriétés d'une seule page.
</Note>

<Note>
  Contrairement aux API Notion et Airtable, Confluence exige que l'appelant incrémente `version.number` à chaque mise à jour, au numéro de version actuel plus un. Envoyer le numéro actuel à nouveau, ou omettre `version`, cause l'échec de la requête. Un assistant IA connecté via MCP utilisant les outils d'Atlassian gère cela automatiquement ; un appel REST direct, comme dans ce script, ne le fait pas.
</Note>

**Exemple :**

```python theme={null}
def find_page(base_url, auth, space_id, title):
    resp = requests.get(
        f"{base_url}/pages",
        auth=auth,
        params={"space-id": space_id, "title": title, "body-format": "storage"},
    )
    resp.raise_for_status()
    results = resp.json().get("results", [])
    return results[0] if results else None


def create_page(base_url, auth, space_id, title, storage_value):
    resp = requests.post(
        f"{base_url}/pages",
        auth=auth,
        headers={"Content-Type": "application/json"},
        json={
            "spaceId": space_id,
            "status": "current",
            "title": title,
            "body": {"representation": "storage", "value": storage_value},
        },
    )
    resp.raise_for_status()
    return resp.json()


def update_page(base_url, auth, page, storage_value):
    resp = requests.put(
        f"{base_url}/pages/{page['id']}",
        auth=auth,
        headers={"Content-Type": "application/json"},
        json={
            "id": page["id"],
            "status": "current",
            "title": page["title"],
            "version": {"number": page["version"]["number"] + 1},
            "body": {"representation": "storage", "value": storage_value},
        },
    )
    resp.raise_for_status()
    return resp.json()


def upsert_page(base_url, auth, space_id, title, new_row):
    page = find_page(base_url, auth, space_id, title)
    if page is None:
        storage_value = build_table_storage([new_row])
        return create_page(base_url, auth, space_id, title, storage_value), "Created"

    existing_header, rows = parse_experiments_table(page["body"]["storage"]["value"])
    rows = upsert_row(existing_header, rows, "Experiment Name", new_row)
    storage_value = build_table_storage(rows)
    return update_page(base_url, auth, page, storage_value), "Updated"
```

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

```json theme={null}
{
  "id": "557057",
  "status": "current",
  "title": "Experiments",
  "spaceId": "327682",
  "version": { "number": 3 },
  "body": {
    "storage": {
      "value": "<table data-layout=\"full-width\"><tbody><tr><th><p>Experiment Name</p></th>...</tr><tr><td><p>Product Page Redesign</p></td><td><p>Completed</p></td>...</tr></tbody></table>"
    }
  }
}
```

## 9. Exécuter le script

Transmettez l'ID de l'expérience, le site Confluence et l'ID d'espace en arguments :

```bash theme={null}
python kameleoon_to_confluence.py \
  --experiment-id 188308 \
  --site your-domain.atlassian.net \
  --space-id 327682
```

Le script affiche chaque étape : l'authentification, l'expérience récupérée, la variation gagnante, la ligne mappée, et s'il a créé ou mis à jour la page Confluence. Transmettez `--page-title` pour cibler une page nommée autrement que le défaut `Experiments`.

## Script complet

Le script complet ci-dessous correspond fonction par fonction à [kameleoon\_to\_confluence.py](/assets/developer-docs/script/apis/automation-api-rest/tutorials/kameleoon_to_confluence.py.zip). 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 a Confluence page.

Given a Kameleoon experiment ID and a Confluence site, space ID, and page
title, 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 Confluence's Experiments table columns.
  7. Finds the Confluence page by title, or creates it if it doesn't exist.
  8. Parses the page's existing table and upserts a row, keyed on
     "Experiment Name".
  9. Writes the updated table back to Confluence.

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

    export KAMELEOON_CLIENT_ID="..."
    export KAMELEOON_CLIENT_SECRET="..."
    export CONFLUENCE_EMAIL="..."
    export CONFLUENCE_API_TOKEN="..."

Usage:

    python kameleoon_to_confluence.py \
        --experiment-id 188308 \
        --site your-domain.atlassian.net \
        --space-id 327682 \
        --page-title Experiments
"""

import argparse
import html
import os
import sys
import time
from html.parser import HTMLParser

import requests
from requests.auth import HTTPBasicAuth

# Mappe les tokens de statut en majuscules retournés par l'Automation API
# sur les valeurs de la colonne Status écrites à Confluence. Ajustez les
# valeurs cibles si vous voulez d'autres libellés, et confirmez les tokens
# que votre compte retourne avec un simple GET /experiments/{experimentId}.
STATUS_MAP = {
    "ACTIVE": "Running",
    "DRAFT": "Implementing",
    "PLANNED": "Implementing",
    "PAUSED": "Defunct",
    "STOPPED": "Completed",
    "DIVERTED": "Completed",
}

# Ordre des colonnes pour le tableau Experiments. Le script indexe les colonnes
# positionnellement par rapport à cet en-tête, à la fois lors de la création
# d'une nouvelle page et lors de l'analyse d'une page existante.
HEADER = [
    "Experiment Name",
    "Status",
    "Start date",
    "End date",
    "Notes",
    "Actual",
    "Probability",
    "Result",
]

TABLE_INTRO = (
    "<p>Kameleoon experiment results, kept current by "
    "kameleoon_to_confluence.py. Each row is upserted by matching the "
    "<strong>Experiment Name</strong> column; re-running the script for the "
    "same experiment updates its row instead of adding a duplicate.</p>"
)


# --------------------------------------------------------------------------- #
# 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": False,
        "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 Confluence columns
# --------------------------------------------------------------------------- #
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_row(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 [fields.get(column) for column in HEADER]


# --------------------------------------------------------------------------- #
# 7. Parse the existing Experiments table
# --------------------------------------------------------------------------- #
class _TableParser(HTMLParser):
    """Extrait la ligne d'en-tête et les lignes de données du premier <table>
    dans un document au format de stockage Confluence. Confluence enveloppe
    le texte des cellules dans des balises <p> et n'émet pas toujours <thead> ;
    une ligne compte comme en-tête la première fois qu'elle est composée de
    cellules <th>. Seul le premier tableau est lu, donc une page avec plus
    d'un tableau (par exemple, un qu'une personne a ajouté à la main) ne
    fusionne jamais la ligne d'en-tête ou les lignes d'un second tableau dans
    le résultat."""

    def __init__(self):
        super().__init__()
        self.header = None
        self.rows = []
        self._row = None
        self._cell = None
        self._row_is_header = False
        self._in_table = False
        self._table_done = False

    def _close_cell(self):
        if self._cell is not None:
            self._row.append("".join(self._cell).strip())
            self._cell = None

    def _close_row(self):
        if self._row is not None:
            self._close_cell()
            if self.header is None and self._row_is_header:
                self.header = self._row
            else:
                self.rows.append(self._row)
            self._row = None

    def handle_starttag(self, tag, attrs):
        if self._table_done:
            return
        if tag == "table" and not self._in_table:
            self._in_table = True
        elif tag == "tr" and self._in_table:
            self._close_row()  # close a still-open row left by an unclosed <tr>
            self._row = []
            self._row_is_header = False
        elif tag in ("td", "th") and self._row is not None:
            self._close_cell()  # close a still-open cell left by an unclosed <td>/<th>
            self._cell = []
            if tag == "th":
                self._row_is_header = True

    def handle_data(self, data):
        if self._cell is not None:
            self._cell.append(data)

    def handle_endtag(self, tag):
        if self._table_done:
            return
        if tag in ("td", "th"):
            self._close_cell()
        elif tag == "tr":
            self._close_row()
        elif tag == "table":
            self._close_row()  # close a still-open row/cell left by an unclosed final <tr>
            self._in_table = False
            self._table_done = True


def parse_experiments_table(storage_value):
    parser = _TableParser()
    parser.feed(storage_value or "")
    return parser.header or HEADER, parser.rows


def reorder_row(source_header, row):
    """Réaligne une ligne analysée par rapport à source_header dans l'ordre HEADER
    canonique, de sorte qu'un tableau dont les colonnes ont été manuellement
    réorganisées dans Confluence ne désaligne pas une recherche basée sur la
    position de colonnes comme la clé Experiment Name. Réaligne seulement une
    véritable réorganisation (les mêmes huit colonnes, ordre différent) ; un
    en-tête avec une colonne renommée ou non reconnue remonte à l'ordre de
    colonnes existant et avertit plutôt que de deviner."""
    if source_header == HEADER:
        return row
    if sorted(source_header) == sorted(HEADER):
        values = dict(zip(source_header, row))
        return [values.get(column) for column in HEADER]
    print(
        f"Warning: table header {source_header} doesn't match the expected "
        f"columns {HEADER}; keeping existing values by position.",
        file=sys.stderr,
    )
    return row


def upsert_row(source_header, rows, key_column, new_row):
    rows = [reorder_row(source_header, row) for row in rows]
    key_index = HEADER.index(key_column)
    key = new_row[key_index]
    for i, row in enumerate(rows):
        if row[key_index] == key:
            rows[i] = new_row
            return rows
    rows.append(new_row)
    return rows


def render_cell(value):
    text = "" if value in (None, "") else html.escape(str(value))
    return f"<p>{text}</p>"


def build_table_storage(rows):
    head_cells = "".join(f"<th>{render_cell(c)}</th>" for c in HEADER)
    body_rows = "".join(
        "<tr>" + "".join(f"<td>{render_cell(v)}</td>" for v in row) + "</tr>"
        for row in rows
    )
    return f'{TABLE_INTRO}<table data-layout="full-width"><tbody><tr>{head_cells}</tr>{body_rows}</tbody></table>'


# --------------------------------------------------------------------------- #
# 8. Find, create, or update the Confluence page
# --------------------------------------------------------------------------- #
def find_page(base_url, auth, space_id, title):
    resp = requests.get(
        f"{base_url}/pages",
        auth=auth,
        params={"space-id": space_id, "title": title, "body-format": "storage"},
    )
    resp.raise_for_status()
    results = resp.json().get("results", [])
    return results[0] if results else None


def create_page(base_url, auth, space_id, title, storage_value):
    resp = requests.post(
        f"{base_url}/pages",
        auth=auth,
        headers={"Content-Type": "application/json"},
        json={
            "spaceId": space_id,
            "status": "current",
            "title": title,
            "body": {"representation": "storage", "value": storage_value},
        },
    )
    resp.raise_for_status()
    return resp.json()


def update_page(base_url, auth, page, storage_value):
    resp = requests.put(
        f"{base_url}/pages/{page['id']}",
        auth=auth,
        headers={"Content-Type": "application/json"},
        json={
            "id": page["id"],
            "status": "current",
            "title": page["title"],
            "version": {"number": page["version"]["number"] + 1},
            "body": {"representation": "storage", "value": storage_value},
        },
    )
    resp.raise_for_status()
    return resp.json()


def upsert_page(base_url, auth, space_id, title, new_row):
    page = find_page(base_url, auth, space_id, title)
    if page is None:
        storage_value = build_table_storage([new_row])
        return create_page(base_url, auth, space_id, title, storage_value), "Created"

    existing_header, rows = parse_experiments_table(page["body"]["storage"]["value"])
    rows = upsert_row(existing_header, rows, "Experiment Name", new_row)
    storage_value = build_table_storage(rows)
    return update_page(base_url, auth, page, storage_value), "Updated"


# --------------------------------------------------------------------------- #
# 9. Orchestration
# --------------------------------------------------------------------------- #
def run(experiment_id, site, space_id, page_title):
    client_id = os.environ.get("KAMELEOON_CLIENT_ID")
    client_secret = os.environ.get("KAMELEOON_CLIENT_SECRET")
    confluence_email = os.environ.get("CONFLUENCE_EMAIL")
    confluence_token = os.environ.get("CONFLUENCE_API_TOKEN")

    missing = [
        name
        for name, value in (
            ("KAMELEOON_CLIENT_ID", client_id),
            ("KAMELEOON_CLIENT_SECRET", client_secret),
            ("CONFLUENCE_EMAIL", confluence_email),
            ("CONFLUENCE_API_TOKEN", confluence_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 a table row.
    row = build_row(experiment, best)
    print("Mapped row:")
    for column, value in zip(HEADER, row):
        print(f"  {column}: {value}")

    # 7-8. Find, create, or update the Confluence page.
    base_url = f"https://{site}/wiki/api/v2"
    auth = HTTPBasicAuth(confluence_email, confluence_token)
    page, action = upsert_page(base_url, auth, space_id, page_title, row)
    print(f"{action} Confluence page {page['id']} ({page_title!r}).")

    return page


def main():
    parser = argparse.ArgumentParser(
        description="Export a Kameleoon experiment's results to Confluence."
    )
    parser.add_argument(
        "--experiment-id",
        required=True,
        help="ID of the Kameleoon experiment to export.",
    )
    parser.add_argument(
        "--site",
        required=True,
        help="Confluence site hostname (for example, your-domain.atlassian.net).",
    )
    parser.add_argument(
        "--space-id",
        required=True,
        help="Numeric Confluence space ID that hosts the Experiments page.",
    )
    parser.add_argument(
        "--page-title",
        default="Experiments",
        help="Title of the page to create or update (default: Experiments).",
    )
    args = parser.parse_args()

    try:
        run(args.experiment_id, args.site, args.space_id, args.page_title)
    except requests.exceptions.RequestException 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 colonnes *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 vous voulez une estimation pré-expérience à la place, supprimez la ligne `Probability` de `build_row`.
* **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.** La correspondance sur *Experiment Name* est exacte, donc des différences de casse ou d'espacement créent une nouvelle ligne au lieu de mettre à jour celle existante. Gardez des noms d'expérience uniques sur la page. Le flux recherche-puis-écriture n'est pas atomique, donc évitez d'exécuter deux exports pour la même expérience simultanément, et évitez de renommer une expérience entre les exécutions sauf si vous voulez aussi une nouvelle ligne pour cela.
* **Mises à jour de page entière.** Chaque mise à jour réécrit le tableau entier, puisque Confluence n'a pas de mise à jour par ligne. Si vous maintenez la page à la main entre les exécutions du script, gardez vos modifications à l'intérieur du tableau que le script construit. Le script ne préserve pas actuellement le contenu en dehors de ce tableau.
* **Conflits de version.** Le script lit toujours le numéro de version actuel de la page immédiatement avant l'écriture, donc une édition manuelle effectuée entre la lecture et l'écriture cause l'échec du `PUT` suivant avec un conflit de version. Réexécutez le script si cela se produit.
* **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 par compte. Confluence Cloud applique ses propres limites de débit, qui varient selon le plan ; consultez [la documentation des limites de débit d'Atlassian](https://developer.atlassian.com/cloud/confluence/rate-limiting/) pour les valeurs actuelles. Si vous traitez de nombreuses expériences par 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.
