---
name: ia-log-analysis
description: "Analyse des fichiers de logs serveur pour identifier et qualifier l'activité des bots IA (LLM Training, User Bots, AI Search Bots). Détecte automatiquement le format de log, classe les bots par catégorie, produit les KPIs de visibilité GEO et les alertes prioritaires. Compatible Combined Log Format Apache/Nginx, W3C, JSON logs."
license: MIT
metadata:
  author: "Agence 30A - Foucauld Henin"
  version: "1.0"
---

# IA Log Analysis Skill — v1

Analyse des logs serveur centrée sur les bots des LLMs. L'objectif est d'identifier les signaux de visibilité GEO à partir des fichiers de logs bruts, quelle que soit leur source ou leur format.

## Principe fondamental

**Un hit de User Bot sur une page HTML = une opportunité de citation dans une réponse IA.**

C'est le signal à maximiser. Tout le reste (LLM Training Bots, AI Search Bots) est secondaire.

---

## Dictionnaire des bots IA

À maintenir à jour. Ce dictionnaire est la base de toute l'analyse — si un bot n'y est pas, il passe inaperçu.

### User Bots (signal de visibilité GEO — priorité maximale)

Ces bots visitent une page parce qu'un utilisateur a posé une question au LLM et que le modèle a jugé cette page pertinente pour y répondre. **1 visite = ~1 citation.**

| Identifiant dans le user-agent | LLM associé |
|-------------------------------|-------------|
| `ChatGPT-User` | ChatGPT / OpenAI |
| `Claude-User` ou `Claude-user` | Claude / Anthropic |
| `Perplexity-User` ou `Perplexity-user` | Perplexity |
| `MistralAI-User` | Mistral |
| `Grok-User` | Grok / xAI |

### AI Search Bots (indexation — impact moyen terme)

Indexent des pages pour alimenter la fonctionnalité de recherche intégrée aux LLMs.

| Identifiant dans le user-agent | LLM associé |
|-------------------------------|-------------|
| `OAI-SearchBot` | ChatGPT Search |
| `PerplexityBot` | Perplexity Search |
| `Claude-SearchBot` | Claude Search |

### LLM Training Bots (entraînement — impact long terme)

Récoltent du contenu pour entraîner ou affiner les modèles. Les bloquer via robots.txt peut exclure votre site des données d'entraînement futures.

| Identifiant dans le user-agent | LLM associé |
|-------------------------------|-------------|
| `GPTBot` | OpenAI |
| `ClaudeBot` ou `anthropic-ai` | Anthropic |
| `CCBot` | Common Crawl (base d'entraînement de nombreux LLMs) |
| `Bytespider` | ByteDance / Doubao |
| `Applebot` | Apple Intelligence |
| `meta-externalagent` ou `Meta-ExternalAgent` | Meta AI |

---

## Détection automatique du format de log

Avant toute analyse, lire les 5 premières lignes et identifier le format.

### Format 1 : Combined Log Format (Apache/Nginx) — le plus courant

```
IP DOMAINE - [DATE] "MÉTHODE URL PROTOCOLE" STATUS TAILLE "REFERER" "USER-AGENT"
```

Exemple :
```
23.98.179.29 www.example.fr - [28/Feb/2025:06:51:31 +0100] "GET /article/ HTTP/1.1" 200 9588 "-" "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot"
```

Regex de parsing :
```python
import re
COMBINED_LOG_PATTERN = re.compile(
    r'(?P<ip>\S+)\s+(?P<domain>\S+)\s+\S+\s+'
    r'\[(?P<datetime>[^\]]+)\]\s+'
    r'"(?P<method>\S+)\s+(?P<url>\S+)\s+\S+"\s+'
    r'(?P<status>\d{3})\s+'
    r'(?P<size>\S+)\s+'
    r'"(?P<referer>[^"]*)"\s+'
    r'"(?P<user_agent>[^"]*)"'
)
```

### Format 2 : Common Log Format (sans referer/user-agent)

```
IP - - [DATE] "MÉTHODE URL PROTOCOLE" STATUS TAILLE
```

### Format 3 : W3C Extended Log Format (IIS)

En-têtes avec `#Fields:`, champs séparés par des espaces, date et heure en colonnes séparées.

### Format 4 : JSON Logs

```json
{"time": "...", "ip": "...", "method": "GET", "url": "...", "status": 200, "user_agent": "..."}
```

### Format 5 : Logs OVH mutualisé

Variante du Combined Log Format avec le domaine en second champ (comme dans les exemples de ce skill).

**Règle** : si le format n'est pas identifié après lecture des premières lignes, afficher un exemple de ligne et demander confirmation avant de continuer.

---

## Règles d'exclusion — ce qu'il ne faut PAS analyser

Toujours exclure ces lignes avant analyse pour ne travailler que sur les hits de pages HTML.

### Extensions de ressources statiques à exclure
```python
STATIC_EXTENSIONS = [
    '.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp',
    '.ico', '.woff', '.woff2', '.ttf', '.eot',
    '.pdf', '.zip', '.xml', '.json', '.txt',
    '.mp4', '.mp3', '.avi', '.mov'
]
```

### Chemins techniques à exclure
```python
EXCLUDED_PATHS = [
    '/wp-content/', '/wp-admin/', '/wp-includes/',
    '/wp-cron.php', '/xmlrpc.php', '/wp-login.php',
    '/sitemap', '/robots.txt', '/favicon',
    '/.well-known/', '/wp-json/'
]
```

### Méthodes HTTP à exclure
```python
EXCLUDED_METHODS = ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']
```

---

## Workflow d'analyse en 4 passes

Exécuter les passes **séquentiellement**, chacune dans un bloc Python séparé.

---

### PASSE 0 : Reconnaissance du fichier

```python
import re
from collections import Counter

# Lire les premières lignes pour identifier le format
with open('/mnt/user-data/uploads/FICHIER.txt', 'r', encoding='utf-8', errors='replace') as f:
    sample_lines = [f.readline() for _ in range(10)]

print("=== RECONNAISSANCE DU FICHIER ===")
print(f"Premières lignes :")
for i, line in enumerate(sample_lines[:5], 1):
    print(f"  [{i}] {line.strip()[:200]}")

# Détection du format
if sample_lines[0].startswith('#Fields:'):
    detected_format = 'W3C'
elif sample_lines[0].strip().startswith('{'):
    detected_format = 'JSON'
elif '"GET' in sample_lines[0] or '"POST' in sample_lines[0]:
    detected_format = 'Combined/Common Log Format'
else:
    detected_format = 'INCONNU - vérification manuelle requise'

print(f"\nFormat détecté : {detected_format}")

# Compter les lignes totales
with open('/mnt/user-data/uploads/FICHIER.txt', 'r', encoding='utf-8', errors='replace') as f:
    total_lines = sum(1 for _ in f)
print(f"Nombre total de lignes : {total_lines:,}")
```

---

### PASSE 1 : Parsing et classification des bots IA

```python
import re
from collections import defaultdict

# Dictionnaire de classification
USER_BOTS = ['chatgpt-user', 'claude-user', 'perplexity-user', 'mistralai-user', 'grok-user']
SEARCH_BOTS = ['oai-searchbot', 'perplexitybot', 'claude-searchbot']
TRAINING_BOTS = ['gptbot', 'claudebot', 'anthropic-ai', 'ccbot', 'bytespider', 'applebot', 'meta-externalagent']

STATIC_EXTENSIONS = ('.css','.js','.png','.jpg','.jpeg','.gif','.svg','.webp','.ico',
                     '.woff','.woff2','.ttf','.eot','.pdf','.zip','.mp4','.mp3')
EXCLUDED_PATHS = ('wp-content','wp-cron','wp-login','xmlrpc','sitemap','robots.txt',
                  'favicon','wp-json','wp-admin','wp-includes')

LOG_PATTERN = re.compile(
    r'\S+\s+\S+\s+\S+\s+\[[^\]]+\]\s+"(?:GET|POST|HEAD|PUT|DELETE|OPTIONS)\s+(\S+)\s+\S+"\s+(\d{3})\s+\S+\s+"[^"]*"\s+"([^"]*)"'
)

def classify_bot(ua_lower):
    for bot in USER_BOTS:
        if bot in ua_lower:
            return 'user_bot', next(b for b in ['ChatGPT-User','Claude-User','Perplexity-User','MistralAI-User','Grok-User'] if b.lower() in ua_lower)
    for bot in SEARCH_BOTS:
        if bot in ua_lower:
            return 'search_bot', next(b for b in ['OAI-SearchBot','PerplexityBot','Claude-SearchBot'] if b.lower() in ua_lower)
    for bot in TRAINING_BOTS:
        if bot in ua_lower:
            return 'training_bot', next(b for b in ['GPTBot','ClaudeBot','anthropic-ai','CCBot','Bytespider','Applebot','meta-externalagent'] if b.lower() in ua_lower)
    return None, None

def is_static_resource(url):
    url_lower = url.lower().split('?')[0]
    if url_lower.endswith(STATIC_EXTENSIONS):
        return True
    if any(p in url_lower for p in EXCLUDED_PATHS):
        return True
    return False

# Parser le fichier
hits = defaultdict(list)  # {category: [(url, status, bot_name), ...]}
total_ia_hits = 0
skipped_static = 0

with open('/mnt/user-data/uploads/FICHIER.txt', 'r', encoding='utf-8', errors='replace') as f:
    for line in f:
        m = LOG_PATTERN.search(line)
        if not m:
            continue
        url, status, ua = m.group(1), m.group(2), m.group(3)
        ua_lower = ua.lower()
        category, bot_name = classify_bot(ua_lower)
        if not category:
            continue
        total_ia_hits += 1
        if is_static_resource(url):
            skipped_static += 1
            continue
        hits[category].append({'url': url, 'status': status, 'bot': bot_name})

print(f"=== CLASSIFICATION DES BOTS IA ===")
print(f"Total hits bots IA (toutes ressources) : {total_ia_hits:,}")
print(f"Dont ressources statiques exclues : {skipped_static:,}")
print(f"Hits sur pages HTML analysés : {sum(len(v) for v in hits.values()):,}")
print(f"\nRépartition par catégorie :")
for cat, data in hits.items():
    print(f"  {cat} : {len(data)} hits")
```

---

### PASSE 2 : KPIs de visibilité GEO

```python
from collections import Counter

# === USER BOTS : le signal principal ===
user_hits = hits.get('user_bot', [])

print("=== USER BOTS — SIGNAL DE VISIBILITÉ GEO ===")
print(f"Total hits User Bots (pages HTML) : {len(user_hits)}")

# Répartition par bot
bot_counts = Counter(h['bot'] for h in user_hits)
print(f"\nHits par bot :")
for bot, count in bot_counts.most_common():
    print(f"  {bot} : {count}")

# Top URLs crawlées par les User Bots
url_counts = Counter(h['url'] for h in user_hits)
print(f"\nTop 20 URLs crawlées par les User Bots (potentiellement citées) :")
for url, count in url_counts.most_common(20):
    print(f"  {count:3d} hits | {url}")

# === DISTRIBUTION DES STATUS CODES ===
print(f"\n=== STATUS CODES PAR TYPE DE BOT ===")
for category, data in hits.items():
    status_counts = Counter(h['status'] for h in data)
    print(f"\n{category} :")
    for status, count in sorted(status_counts.items()):
        label = "✅ OK" if status == '200' else ("↗ Redirect" if status.startswith('3') else ("❌ Erreur client" if status.startswith('4') else ("💥 Erreur serveur" if status.startswith('5') else "⚠️ Connexion coupée")))
        print(f"  HTTP {status} : {count} hits {label}")

# === AI SEARCH BOTS ===
search_hits = hits.get('search_bot', [])
print(f"\n=== AI SEARCH BOTS ===")
print(f"Total hits : {len(search_hits)}")
search_url_counts = Counter(h['url'] for h in search_hits)
print(f"Top 10 URLs indexées :")
for url, count in search_url_counts.most_common(10):
    print(f"  {count:3d} | {url}")
```

---

### PASSE 3 : Alertes et recommandations

```python
# === ALERTE 1 : Pages 4xx visitées par des User Bots ===
# = Citations potentielles ratées
errors_user = [h for h in user_hits if h['status'].startswith('4')]
print("=== ⚠️ ALERTE : PAGES EN ERREUR VISITÉES PAR DES USER BOTS ===")
if errors_user:
    print(f"🔴 {len(errors_user)} hits sur pages en erreur — citations potentiellement ratées")
    error_urls = Counter(h['url'] for h in errors_user)
    for url, count in error_urls.most_common(10):
        print(f"  {count} hits | {url}")
    print("  → Action : corriger ces URLs (redirection 301 ou restauration du contenu)")
else:
    print("  ✅ Aucune erreur 4xx sur les User Bots")

# === ALERTE 2 : Pages 499/460 (timeout) ===
# = Bot a coupé la connexion avant réponse du serveur → TTFB trop lent
all_hits = [h for v in hits.values() for h in v]
timeouts = [h for h in all_hits if h['status'] in ('499', '460')]
print(f"\n=== ⚠️ ALERTE : TIMEOUTS BOTS IA (status 499/460) ===")
if timeouts:
    print(f"🔴 {len(timeouts)} timeouts détectés — serveur trop lent pour les bots IA")
    print(f"  → Action : optimiser le TTFB (cible < 500ms). Les LLMs ne patientent pas comme Google.")
else:
    print(f"  ✅ Aucun timeout détecté")

# === ALERTE 3 : Pages très fréquemment citées ===
# = Vos assets GEO à protéger et renforcer en priorité
top_user_urls = url_counts.most_common(5)
print(f"\n=== 🏆 VOS PAGES LES PLUS CITÉES PAR LES LLMs ===")
for url, count in top_user_urls:
    print(f"  {count} citations | {url}")
    print(f"  → Action : renforcer le contenu, maillage interne, backlinks sur cette page")

# === RECOMMANDATIONS FINALES ===
print(f"\n=== RECOMMANDATIONS PRIORITAIRES ===")
reco_index = 1

if errors_user:
    print(f"  {reco_index}. 🔴 URGENT — Corriger {len(set(h['url'] for h in errors_user))} pages en erreur visitées par des User Bots")
    reco_index += 1

if timeouts:
    print(f"  {reco_index}. 🔴 URGENT — Réduire le TTFB (timeouts détectés). Cible : < 500ms")
    reco_index += 1

if top_user_urls:
    top_url = top_user_urls[0][0]
    print(f"  {reco_index}. ✍️ Enrichir le contenu de {top_url} (page la plus citée). Cible : 1200+ mots, FAQ, données chiffrées")
    reco_index += 1

user_ratio = len(user_hits) / max(sum(len(v) for v in hits.values()), 1) * 100
if user_ratio < 20:
    print(f"  {reco_index}. 📈 Ratio User Bot faible ({user_ratio:.0f}%). Améliorer la qualité du contenu pour passer de l'indexation à la citation.")
    reco_index += 1

print(f"\n  Rappel : les pages citées par les LLMs ont en commun :")
print(f"  - Contenu dense (800+ mots), structuré, avec réponses directes aux questions")
print(f"  - Fort maillage interne vers elles")
print(f"  - Bon positionnement Google (les LLMs crawlent ce que Google remonte)")
```

---

## Format de restitution

Après les 4 passes, produire un rapport structuré :

### 1. Résumé exécutif (5 lignes max)

- Total hits bots IA analysés (hors ressources statiques)
- Répartition User / Search / Training
- Nombre de pages distinctes potentiellement citées
- Alertes critiques (erreurs 4xx, timeouts)
- Top 1 page citée

### 2. Tableau de synthèse

| Bot | Catégorie | Hits pages | Pages uniques | Status dominant |
|-----|-----------|------------|---------------|-----------------|
| ChatGPT-User | User Bot | 18 | 11 | 200 (95%) |
| OAI-SearchBot | AI Search | 6 | 5 | 200 (83%) |
| PerplexityBot | Training | 7 | 6 | 200 (100%) |

### 3. Top pages citées (User Bots uniquement)

Tableau trié par nombre de hits décroissant, avec code HTTP et bot(s) concerné(s).

### 4. Alertes

Section dédiée avec niveau de priorité (🔴 urgent / 🟡 à planifier) et action concrète.

### 5. Recommandations GEO — 3 à 5 actions

Format : **Quoi faire** → **Sur quelle(s) page(s)** → **Impact attendu**

---

## Gestion des cas particuliers

**Fichier .gz compressé**
```python
import gzip
with gzip.open('/mnt/user-data/uploads/FICHIER.gz', 'rt', encoding='utf-8', errors='replace') as f:
    # même parsing
```

**Plusieurs fichiers de logs (multi-jours)**  
Concaténer avant analyse ou parser séquentiellement en agrégeant les compteurs.

**Format non reconnu**  
Afficher 5 exemples de lignes et demander : *"Quel est le séparateur entre les champs ? Où se trouve le user-agent dans cette ligne ?"*

**Gros volumes (> 200 Mo)**  
Utiliser le streaming (lecture ligne par ligne comme dans le code ci-dessus) — ne jamais charger le fichier entier en mémoire.

---

## Exemples de prompts utilisateur

- "Analyse ces logs et dis-moi quels bots IA crawlent mon site"
- "Quelles pages de mon site sont citées par ChatGPT ?"
- "Analyse ces logs serveur et donne-moi mes KPIs de visibilité GEO"
- "Y a-t-il des erreurs qui empêchent les bots IA de lire mes pages ?"
- "Compare l'activité des User Bots vs les Search Bots dans ces logs"
