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

# Automatisation avancée

> Cas d'usage avancés pour agences SEO et power users : commandes en masse, dashboards, alertes et workflows clients complets.

# Automatisation avancée

Ce guide s'adresse aux agences SEO et aux utilisateurs avancés qui souhaitent pousser l'API Linkuma à son plein potentiel : imports CSV massifs, dashboards personnalisés, alertes en temps réel et workflows de bout en bout.

<Info>
  Les exemples ci-dessous supposent que vous disposez d'un Bearer token valide et que vous avez lu la [documentation de référence de l'API](/api-reference/introduction).
</Info>

***

## Commandes en masse via CSV + API

### Principe

Plutôt que de saisir chaque commande manuellement, préparez un fichier CSV avec toutes vos URLs et ancres, puis laissez un script Python passer les commandes automatiquement.

### Format du CSV attendu

```csv theme={null}
project_id,target_url,anchor,budget_max,niche
proj_001,https://monsite.fr/produit-a,chaussures running,35,sport
proj_001,https://monsite.fr/produit-b,trail homme,50,sport
proj_002,https://agence-client.fr/seo,consultant SEO Paris,70,marketing
proj_002,https://agence-client.fr/audit,audit SEO gratuit,35,marketing
```

### Script Python : import CSV en masse

```python theme={null}
import csv
import time
import requests
from pathlib import Path

API_BASE = "https://app.linkuma.com/api/v1"
API_TOKEN = "VOTRE_TOKEN_ICI"
CSV_FILE = Path("commandes.csv")

HEADERS = {
    "Authorization": f"Bearer {API_TOKEN}",
    "Content-Type": "application/json"
}

# Paramètres de sécurité
DELAY_BETWEEN_REQUESTS = 1.2  # secondes (respecte le rate limit 60 req/min)
DRY_RUN = False  # Passer à True pour simuler sans passer de commandes


def place_order(row: dict) -> dict:
    """Passe une commande depuis une ligne CSV."""
    payload = {
        "project_id": row["project_id"],
        "target_url": row["target_url"],
        "anchor": row["anchor"],
        "budget_max": int(row["budget_max"]),
        "niche": row.get("niche", "auto")
    }

    if DRY_RUN:
        print(f"[DRY RUN] Commande simulée : {payload['target_url']} ({payload['anchor']})")
        return {"data": {"order_id": "dry_run", "status": "simulated"}}

    response = requests.post(f"{API_BASE}/orders", headers=HEADERS, json=payload)
    response.raise_for_status()
    return response.json()


def process_csv(filepath: Path):
    results = []
    errors = []

    with open(filepath, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        rows = list(reader)

    print(f"{len(rows)} commandes à traiter...")

    for i, row in enumerate(rows, 1):
        print(f"[{i}/{len(rows)}] {row['target_url']}", end=" → ")
        try:
            result = place_order(row)
            order_id = result["data"]["order_id"]
            print(f"OK ({order_id})")
            results.append({**row, "order_id": order_id, "status": "success"})
        except requests.HTTPError as e:
            print(f"ERREUR ({e.response.status_code})")
            errors.append({**row, "error": str(e)})
        except Exception as e:
            print(f"ERREUR inattendue ({e})")
            errors.append({**row, "error": str(e)})

        time.sleep(DELAY_BETWEEN_REQUESTS)

    # Rapport
    print(f"\n✅ Succès : {len(results)} | ❌ Erreurs : {len(errors)}")

    # Export des erreurs pour retraitement
    if errors:
        error_file = filepath.with_name(f"{filepath.stem}_errors.csv")
        with open(error_file, "w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=errors[0].keys())
            writer.writeheader()
            writer.writerows(errors)
        print(f"Erreurs exportées dans : {error_file}")

    return results, errors


if __name__ == "__main__":
    process_csv(CSV_FILE)
```

<Warning>
  Ne dépassez pas 50 commandes par batch sans pause. Si vous traitez plus de 50 URLs, ajoutez une pause de 5 secondes toutes les 50 requêtes pour éviter tout blocage temporaire de votre token.
</Warning>

***

## Dashboard custom de suivi

### Script Python : génération d'un rapport HTML

Ce script interroge l'API et génère un fichier HTML autonome avec un tableau de bord complet.

```python theme={null}
import requests
from datetime import datetime
from pathlib import Path

API_BASE = "https://app.linkuma.com/api/v1"
API_TOKEN = "VOTRE_TOKEN_ICI"
HEADERS = {"Authorization": f"Bearer {API_TOKEN}"}


def fetch_all_orders() -> list:
    orders, page = [], 1
    while True:
        res = requests.get(
            f"{API_BASE}/orders",
            headers=HEADERS,
            params={"limit": 100, "page": page}
        )
        res.raise_for_status()
        data = res.json()
        batch = data.get("data", [])
        if not batch:
            break
        orders.extend(batch)
        if len(orders) >= data["meta"]["total"]:
            break
        page += 1
    return orders


def generate_dashboard(orders: list, output_path: str = "linkuma-dashboard.html"):
    # Calcul des métriques
    total = len(orders)
    delivered = sum(1 for o in orders if o["status"] == "delivered")
    in_progress = sum(1 for o in orders if o["status"] == "in_progress")
    pending = sum(1 for o in orders if o["status"] == "pending")
    total_spent = sum(o.get("amount", 0) for o in orders)

    rows_html = ""
    for o in orders:
        status_badge = {
            "delivered": '<span style="color:green">✅ Livré</span>',
            "in_progress": '<span style="color:orange">⏳ En cours</span>',
            "pending": '<span style="color:gray">🕐 En attente</span>',
            "cancelled": '<span style="color:red">❌ Annulé</span>',
        }.get(o["status"], o["status"])

        rows_html += f"""
        <tr>
          <td>{o.get('order_id','N/A')}</td>
          <td>{o.get('project_name','N/A')}</td>
          <td><a href="{o.get('target_url','')}" target="_blank">{o.get('target_url','N/A')[:50]}…</a></td>
          <td>{o.get('anchor','N/A')}</td>
          <td>{status_badge}</td>
          <td>{o.get('amount','N/A')}€</td>
        </tr>"""

    html = f"""<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <title>Dashboard Linkuma</title>
  <style>
    body {{ font-family: sans-serif; padding: 2rem; background: #f9f9f9; }}
    h1 {{ color: #1a1a2e; }}
    .cards {{ display: flex; gap: 1rem; margin-bottom: 2rem; }}
    .card {{ background: white; border-radius: 8px; padding: 1.5rem; flex: 1;
             box-shadow: 0 2px 8px rgba(0,0,0,.08); text-align: center; }}
    .card .value {{ font-size: 2rem; font-weight: bold; color: #1a1a2e; }}
    table {{ width: 100%; border-collapse: collapse; background: white;
             border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.08); }}
    th {{ background: #1a1a2e; color: white; padding: .75rem 1rem; text-align: left; }}
    td {{ padding: .65rem 1rem; border-bottom: 1px solid #eee; font-size: .9rem; }}
    tr:last-child td {{ border-bottom: none; }}
  </style>
</head>
<body>
  <h1>Dashboard Linkuma : {datetime.now().strftime('%d/%m/%Y %H:%M')}</h1>
  <div class="cards">
    <div class="card"><div class="value">{total}</div>Total commandes</div>
    <div class="card"><div class="value" style="color:green">{delivered}</div>Livrées</div>
    <div class="card"><div class="value" style="color:orange">{in_progress}</div>En cours</div>
    <div class="card"><div class="value" style="color:gray">{pending}</div>En attente</div>
    <div class="card"><div class="value">{total_spent}€</div>Dépensé</div>
  </div>
  <table>
    <thead><tr>
      <th>ID</th><th>Projet</th><th>URL cible</th><th>Ancre</th><th>Statut</th><th>Montant</th>
    </tr></thead>
    <tbody>{rows_html}</tbody>
  </table>
</body>
</html>"""

    Path(output_path).write_text(html, encoding="utf-8")
    print(f"Dashboard généré : {output_path}")


if __name__ == "__main__":
    orders = fetch_all_orders()
    generate_dashboard(orders)
```

***

## Alertes automatiques

Recevez une notification dès qu'une commande change de statut, sans vous connecter manuellement à la plateforme.

### Webhook Slack : commande livrée

```python theme={null}
import requests
import json

LINKUMA_TOKEN = "VOTRE_TOKEN_ICI"
SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/XXX/YYY/ZZZ"
API_BASE = "https://app.linkuma.com/api/v1"

def check_newly_delivered(last_check_timestamp: str) -> list:
    """Récupère les commandes livrées depuis le dernier check."""
    res = requests.get(
        f"{API_BASE}/orders",
        headers={"Authorization": f"Bearer {LINKUMA_TOKEN}"},
        params={"status": "delivered", "delivered_after": last_check_timestamp}
    )
    res.raise_for_status()
    return res.json().get("data", [])


def notify_slack(order: dict):
    """Envoie une notification Slack pour une commande livrée."""
    payload = {
        "text": f"*Backlink livré ✅*",
        "blocks": [
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": (
                        f"*Commande #{order['order_id']}* livrée !\n"
                        f"• Projet : `{order['project_name']}`\n"
                        f"• URL cible : <{order['target_url']}|{order['target_url']}>\n"
                        f"• Ancre : `{order['anchor']}`\n"
                        f"• Lien livré : <{order.get('delivered_url', 'N/A')}|Voir le lien>"
                    )
                }
            }
        ]
    }
    requests.post(SLACK_WEBHOOK_URL, json=payload)
    print(f"Slack notifié pour {order['order_id']}")


# Exécution (à appeler via cron toutes les heures)
if __name__ == "__main__":
    from datetime import datetime, timedelta, timezone
    one_hour_ago = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
    orders = check_newly_delivered(one_hour_ago)
    for order in orders:
        notify_slack(order)
    print(f"{len(orders)} commande(s) notifiée(s).")
```

<Tip>
  Planifiez ce script via `cron` ou un service comme GitHub Actions pour une surveillance continue. Exemple crontab : `0 * * * * python3 /opt/scripts/linkuma-alerts.py`
</Tip>

***

## Rotation de projets et budgets

Pour les agences gérant plusieurs clients, automatisez la répartition des commandes selon les budgets alloués.

```python theme={null}
import requests
from dataclasses import dataclass

API_BASE = "https://app.linkuma.com/api/v1"
API_TOKEN = "VOTRE_TOKEN_ICI"
HEADERS = {"Authorization": f"Bearer {API_TOKEN}", "Content-Type": "application/json"}


@dataclass
class Client:
    name: str
    project_id: str
    monthly_budget: float  # Budget mensuel en euros
    spent: float = 0.0

    @property
    def remaining(self) -> float:
        return self.monthly_budget - self.spent


def get_project_spending(project_id: str) -> float:
    """Calcule le total dépensé sur un projet ce mois-ci."""
    from datetime import datetime
    start_of_month = datetime.now().replace(day=1, hour=0, minute=0, second=0).isoformat()
    res = requests.get(
        f"{API_BASE}/orders",
        headers=HEADERS,
        params={"project_id": project_id, "created_after": start_of_month}
    )
    res.raise_for_status()
    orders = res.json().get("data", [])
    return sum(o.get("amount", 0) for o in orders)


def allocate_orders(clients: list[Client], orders_to_place: list[dict]):
    """
    Répartit une liste de commandes entre les clients
    en respectant leurs budgets restants.
    """
    # Mise à jour du spending actuel
    for client in clients:
        client.spent = get_project_spending(client.project_id)
        print(f"{client.name}: {client.spent}€ / {client.monthly_budget}€ (reste: {client.remaining}€)")

    placed = []
    skipped = []

    for order in orders_to_place:
        client = next((c for c in clients if c.project_id == order["project_id"]), None)
        if not client:
            skipped.append({**order, "reason": "projet introuvable"})
            continue

        if client.remaining < order["budget_max"]:
            skipped.append({**order, "reason": f"budget insuffisant ({client.remaining}€ restant)"})
            continue

        # Passer la commande
        res = requests.post(f"{API_BASE}/orders", headers=HEADERS, json=order)
        if res.ok:
            client.spent += order["budget_max"]
            placed.append(order)
            print(f"✅ Commande placée pour {client.name}: {order['target_url']}")
        else:
            skipped.append({**order, "reason": f"API error {res.status_code}"})

    print(f"\nBilan : {len(placed)} placées, {len(skipped)} ignorées")
    return placed, skipped
```

***

## Monitoring SEO avec Google Search Console

Combinez l'API Linkuma avec l'API Google Search Console pour mesurer l'impact réel de vos backlinks sur vos positions.

### Script Python : suivi de positions après achat de liens

```python theme={null}
import requests
from datetime import datetime, timedelta
from google.oauth2 import service_account
from googleapiclient.discovery import build

# Configuration Google Search Console
SCOPES = ['https://www.googleapis.com/auth/webmasters.readonly']
SERVICE_ACCOUNT_FILE = 'credentials.json'
SITE_URL = 'https://votresite.fr'

# Pages cibles à surveiller (celles sur lesquelles vous achetez des liens)
PAGES_TO_MONITOR = [
    '/categorie-principale/',
    '/produit-best-seller/',
    '/guide-achat/',
]

def get_gsc_data(start_date: str, end_date: str, pages: list) -> list:
    """Récupère les données de positions depuis Google Search Console."""
    credentials = service_account.Credentials.from_service_account_file(
        SERVICE_ACCOUNT_FILE, scopes=SCOPES
    )
    service = build('searchconsole', 'v1', credentials=credentials)

    results = []
    for page in pages:
        response = service.searchanalytics().query(
            siteUrl=SITE_URL,
            body={
                'startDate': start_date,
                'endDate': end_date,
                'dimensions': ['query', 'page'],
                'dimensionFilterGroups': [{
                    'filters': [{
                        'dimension': 'page',
                        'operator': 'contains',
                        'expression': page
                    }]
                }],
                'rowLimit': 25
            }
        ).execute()

        for row in response.get('rows', []):
            results.append({
                'page': page,
                'query': row['keys'][0],
                'clicks': row['clicks'],
                'impressions': row['impressions'],
                'ctr': round(row['ctr'] * 100, 2),
                'position': round(row['position'], 1)
            })

    return results


def compare_periods(days_back: int = 30):
    """Compare les positions entre deux périodes pour mesurer la progression."""
    today = datetime.now()

    # Période actuelle
    current_end = (today - timedelta(days=3)).strftime('%Y-%m-%d')
    current_start = (today - timedelta(days=3 + days_back)).strftime('%Y-%m-%d')

    # Période précédente
    previous_end = (today - timedelta(days=3 + days_back)).strftime('%Y-%m-%d')
    previous_start = (today - timedelta(days=3 + days_back * 2)).strftime('%Y-%m-%d')

    current = get_gsc_data(current_start, current_end, PAGES_TO_MONITOR)
    previous = get_gsc_data(previous_start, previous_end, PAGES_TO_MONITOR)

    # Créer un index pour la comparaison
    prev_index = {(r['page'], r['query']): r for r in previous}

    print(f"\n📊 Évolution des positions ({previous_start} → {current_end})\n")
    print(f"{'Page':<30} {'Mot-clé':<35} {'Pos. avant':>10} {'Pos. après':>10} {'Δ':>6}")
    print("-" * 95)

    for row in sorted(current, key=lambda x: x['position']):
        key = (row['page'], row['query'])
        prev = prev_index.get(key)
        if prev:
            delta = prev['position'] - row['position']
            arrow = '↑' if delta > 0 else '↓' if delta < 0 else '='
            print(f"{row['page']:<30} {row['query']:<35} {prev['position']:>10.1f} {row['position']:>10.1f} {arrow}{abs(delta):>4.1f}")


if __name__ == '__main__':
    compare_periods(30)
```

<Tip>
  Lancez ce script 4 à 8 semaines après vos commandes de backlinks pour mesurer l'impact réel sur vos positions. Les backlinks mettent généralement 3 à 12 semaines pour produire leur plein effet.
</Tip>

***

## Workflow agence complet

Ce workflow couvre le cycle de vie complet d'une mission client : du brief initial à la livraison du rapport.

<Steps>
  <Step title="Réception du brief client">
    Le client remplit un formulaire Google Forms ou Typeform avec :

    * Les URLs à booster
    * Les ancres souhaitées
    * Le budget mensuel
    * La thématique du site

    Un Zap ou Make récupère les réponses et les formate en CSV.
  </Step>

  <Step title="Création automatique du projet Linkuma">
    ```bash theme={null}
    curl -X POST https://app.linkuma.com/api/v1/projects \
      -H "Authorization: Bearer VOTRE_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Client : Nom du site",
        "website": "https://site-client.fr",
        "niche": "e-commerce",
        "notes": "Brief reçu le 21/02/2026 via Typeform"
      }'
    ```
  </Step>

  <Step title="Import des commandes depuis le CSV du brief">
    Lancez le script d'import CSV décrit dans la section précédente avec le `project_id` nouvellement créé.
  </Step>

  <Step title="Monitoring automatique">
    Configurez le script d'alertes Slack pour surveiller les livraisons. Le client peut être invité dans un canal Slack dédié pour suivre en temps réel.
  </Step>

  <Step title="Génération du rapport mensuel">
    À J+30, lancez le script de dashboard HTML et transmettez-le au client par email ou partagez-le via un lien Drive.

    ```bash theme={null}
    python3 generate-dashboard.py --project proj_001 --output rapport-client-fevrier.html
    ```
  </Step>
</Steps>

<Accordion title="Exemple de rapport automatisé envoyé par email">
  ```python theme={null}
  import smtplib
  from email.mime.multipart import MIMEMultipart
  from email.mime.text import MIMEText
  from email.mime.base import MIMEBase
  from email import encoders
  from pathlib import Path

  def send_report(to_email: str, client_name: str, report_path: str):
      """Envoie le rapport HTML en pièce jointe par email."""
      msg = MIMEMultipart()
      msg["From"] = "reporting@votre-agence.fr"
      msg["To"] = to_email
      msg["Subject"] = f"Rapport Linkuma : {client_name} (Février 2026)"

      body = f"""Bonjour,

  Veuillez trouver en pièce jointe votre rapport de backlinks Linkuma pour le mois de février.

  Résumé :
  - X liens livrés
  - Budget consommé : YY€
  - Prochaine livraison estimée : dans Z jours

  Pour toute question, répondez à cet email.

  Cordialement,
  L'équipe SEO"""

      msg.attach(MIMEText(body, "plain"))

      with open(report_path, "rb") as f:
          part = MIMEBase("application", "octet-stream")
          part.set_payload(f.read())
          encoders.encode_base64(part)
          part.add_header("Content-Disposition", f"attachment; filename={Path(report_path).name}")
          msg.attach(part)

      with smtplib.SMTP_SSL("smtp.votre-agence.fr", 465) as server:
          server.login("reporting@votre-agence.fr", "MOT_DE_PASSE")
          server.send_message(msg)

      print(f"Rapport envoyé à {to_email}")
  ```
</Accordion>

***

## Récapitulatif des bonnes pratiques

<CardGroup cols={2}>
  <Card title="Respectez le rate limit" icon="gauge-high">
    Ajoutez toujours un délai de 1 seconde entre vos requêtes en mode batch. Au-delà de 60 req/min, l'API retourne un code `429 Too Many Requests`.
  </Card>

  <Card title="Gérez les erreurs" icon="triangle-exclamation">
    Implémentez un retry avec backoff exponentiel pour les codes `429` et `5xx`. Ne relancez pas en cas de `400` ou `404` sans corriger le payload.
  </Card>

  <Card title="Utilisez DRY_RUN" icon="flask">
    Avant tout passage massif, simulez toujours avec le mode `DRY_RUN = True` pour valider votre CSV et vos mappings sans consommer de budget.
  </Card>

  <Card title="Exportez les logs" icon="file-lines">
    Conservez un fichier de log horodaté pour chaque batch. En cas d'incident, vous pourrez identifier rapidement les commandes concernées.
  </Card>
</CardGroup>
