#!/usr/bin/env python3
"""Qui, dans l'industrie de la vie privee, laisse entrer les robots d'IA ?

Lit le `robots.txt` de chaque site de la verticale VPN / gestionnaires de mots de
passe / messageries chiffrees / outils vie privee, et enregistre, pour huit agents
d'IA connus, si le site les AUTORISE, les BLOQUE, ou ne dit rien.

Ce que ce jeu de donnees mesure : ce que le fichier `robots.txt` DECLARE, et rien
d'autre. `robots.txt` est une convention, pas un controle d'acces : un site qui
bloque GPTBot n'empeche personne de le lire, il demande. Et un site qui n'en parle
pas n'a pas forcement pris de decision - le plus souvent il n'y a pas pense.
Ce n'est donc PAS un classement, et il n'y a pas de bonne reponse : autoriser sert
la visibilite, bloquer sert le controle. La question interessante est ailleurs -
**une industrie qui vend le controle des donnees a-t-elle, chez elle, pris position ?**

Regles de lecture appliquees, dans cet ordre (celles de la specification robots) :
  1. un groupe `User-agent:` nommant l'agent l'emporte sur le groupe `*` ;
  2. dans le groupe retenu, `Disallow: /` = BLOQUE ; toute autre regle = AUTORISE ;
  3. aucun groupe applicable = NON DECLARE (champ `-`).

Une erreur de collecte laisse le champ VIDE, jamais un zero et jamais "non declare" :
confondre "non mesure" et "absent" est la faute qui rend un releve faux sans alerte.

Sortie : CSV sur stdout.  Licence : MIT.  Reproductible : relancer donne l'etat du jour.
Usage : python3 v004-ai-crawlers-scan.py sites.csv > sortie.csv
"""
import urllib.request
import urllib.error
import ssl
import csv
import sys
import datetime
import time

UA = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                    "(KHTML, like Gecko) Chrome/126 Safari/537.36"}

# Agents mesures. Entrainement et recherche melanges volontairement : la question
# posee est "le site a-t-il pris position", pas "a-t-il pris LA bonne position".
AGENTS = [
    "GPTBot",            # OpenAI, entrainement
    "OAI-SearchBot",     # OpenAI, index de recherche (distinct de GPTBot)
    "ChatGPT-User",      # OpenAI, recuperation a la demande d'un utilisateur
    "ClaudeBot",         # Anthropic
    "PerplexityBot",     # Perplexity
    "Google-Extended",   # Google, jeton d'entrainement Gemini (pas le crawl Search)
    "Applebot-Extended", # Apple, jeton d'entrainement
    "CCBot",             # Common Crawl, source amont de nombreux corpus
]

TIMEOUT = 15
CTX = ssl.create_default_context()


def fetch_robots(domain):
    """Rend (texte, erreur). Un 404 est une REPONSE (pas de robots.txt), pas une erreur."""
    for scheme in ("https", "http"):
        url = f"{scheme}://{domain}/robots.txt"
        try:
            req = urllib.request.Request(url, headers=UA)
            with urllib.request.urlopen(req, timeout=TIMEOUT, context=CTX) as r:
                body = r.read(500_000).decode("utf-8", "replace")
                ctype = (r.headers.get("content-type") or "").lower()
                # Un site qui sert son HTML d'accueil sur /robots.txt n'a pas de robots.txt.
                if "html" in ctype or body.lstrip()[:1] == "<":
                    return "", None
                return body, None
        except urllib.error.HTTPError as e:
            if e.code == 404:
                return "", None          # pas de robots.txt : reponse valide
            last = f"http_{e.code}"
        except Exception as e:            # noqa: BLE001 - on veut la cause en clair
            last = type(e).__name__
    return None, last                     # non mesurable -> champs VIDES


def parse_groups(text):
    """Rend {user_agent_minuscule: [lignes de regles]}. Gere les User-agent empiles."""
    groups, current = {}, []
    expecting_agent = False
    for raw in text.splitlines():
        line = raw.split("#", 1)[0].strip()
        if not line or ":" not in line:
            continue
        field, _, value = line.partition(":")
        field, value = field.strip().lower(), value.strip()
        if field == "user-agent":
            if not expecting_agent:
                current = []
                expecting_agent = True
            agent = value.lower()
            groups.setdefault(agent, current)
            # Deux User-agent qui se suivent partagent le MEME bloc de regles.
            groups[agent] = current
        elif field in ("disallow", "allow"):
            expecting_agent = False
            current.append((field, value))
    return groups


def verdict(groups, agent):
    """Verdict pour un agent.

    Quatre valeurs, et la distinction entre les deux premieres et les deux
    suivantes est TOUT l'interet du jeu de donnees :
      ALLOW / BLOCK     - l'agent est NOMME dans le robots.txt : decision prise
      ALLOW* / BLOCK*   - herite du groupe `*` : le site n'a jamais parle de cet agent
      -                 - aucun groupe applicable

    Confondre "autorise" et "n'en a jamais parle" ferait dire au releve que toute
    l'industrie accueille les IA, alors qu'elle n'a majoritairement rien decide.
    """
    named = agent.lower() in groups
    rules = groups.get(agent.lower())
    if rules is None:
        rules = groups.get("*")
        if rules is None:
            return "-"
    # Disallow: / (racine entiere) = blocage total. Disallow vide = autorisation.
    blocked = any(f == "disallow" and v == "/" for f, v in rules)
    return ("BLOCK" if blocked else "ALLOW") + ("" if named else "*")


def main():
    if len(sys.argv) < 2:
        sys.exit("usage: v004-ai-crawlers-scan.py <fichier_sites.csv>")
    with open(sys.argv[1], encoding="utf-8") as fh:
        domains = [r["site"].strip() for r in csv.DictReader(fh) if r["site"].strip()]

    today = datetime.date.today().isoformat()
    out = csv.writer(sys.stdout)
    out.writerow(["site", "has_robots_txt", "stance"] + AGENTS + ["error", "checked_at"])

    for i, domain in enumerate(domains, 1):
        text, err = fetch_robots(domain)
        if text is None:
            # Non mesurable : tous les champs d'agent restent VIDES.
            out.writerow([domain, "", ""] + [""] * len(AGENTS) + [err, today])
        else:
            groups = parse_groups(text) if text else {}
            verdicts = [verdict(groups, a) for a in AGENTS]
            # Seuls les agents NOMMES comptent comme une position prise.
            decided = [v for v in verdicts if v in ("ALLOW", "BLOCK")]
            if not decided:
                stance = "silent"                       # n'a nomme aucun agent d'IA
            elif len(decided) < len(AGENTS):
                stance = "partial"                      # en a nomme certains seulement
            elif all(v == "BLOCK" for v in decided):
                stance = "blocks_all"
            elif all(v == "ALLOW" for v in decided):
                stance = "allows_all"
            else:
                stance = "mixed"                        # a trie agent par agent
            out.writerow([domain, "yes" if text else "no", stance] + verdicts + ["", today])
        sys.stdout.flush()
        print(f"  {i}/{len(domains)} {domain}", file=sys.stderr)
        time.sleep(0.4)                                 # un hote different a chaque fois


if __name__ == "__main__":
    main()
