#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Eric Gerard
"""collecte-tiers-sites-publics : a combien de domaines TIERS la page d'accueil
d'un organisme public francais fait-elle appel, et declare-t-elle une politique
de referent ?

POURQUOI CE RELEVE EXISTE. Un usager qui consulte le site de sa mairie n'a pas
choisi d'etre expose a des serveurs tiers. Chaque domaine tiers appele par la
page d'accueil recoit son adresse IP et, si l'en-tete `Referrer-Policy` est
absent ou permissif, l'URL exacte de la page consultee. Ces deux faits se
mesurent sans jugement, en une requete, sur la page d'accueil. Personne ne
publie ce chiffre pour les sites publics francais.

⛔ CE QUE CE RELEVE NE DIT PAS. Il doit etre lu avec ces quatre reserves, qui
sont reproduites dans le depot et pas seulement ici :
  1. il porte sur la PAGE D'ACCUEIL, pas sur le site entier ;
  2. un domaine tiers n'est PAS un traceur. Une police de caracteres, une carte
     ou un lecteur video sont des tiers legitimes. Le chiffre mesure une
     EXPOSITION, jamais une intention ni une infraction ;
  3. il ne compte que les tiers presents dans le HTML servi. Ceux qu'un script
     ajoute apres coup dans le navigateur ne sont PAS vus : le chiffre est donc
     un PLANCHER, jamais un total ;
  4. un domaine qui ne repond pas n'est pas un domaine fautif. Il est ABSENT du
     calcul et compte a part.

⚠️ Aucune conclusion de conformite RGPD ne peut se tirer de ce fichier. Il
mesure ce qu'une page demande au navigateur, pas ce qu'un responsable de
traitement a declare.

Une requete GET par domaine, un User-Agent qui se nomme, huit en parallele.

Usage :
    python3 scripts/collecte-tiers-sites-publics.py <source.csv> <sortie.csv> [taille]
"""
from __future__ import annotations

import concurrent.futures
import csv
import gzip
import io
import json
import os
import re
import sys
import urllib.error
import urllib.request
from collections import Counter
from datetime import date, timezone, datetime
from urllib.parse import urlparse

UA = ("Mozilla/5.0 (compatible; ReleveTiersSitesPublics/1.0; "
      "+https://github.com/ricco020/tiers-sites-publics) donnee ouverte, une requete par domaine")
DELAI = 12

# Un attribut qui fait charger une ressource par le navigateur. On ne compte
# QUE ceux-la : un <a href> vers un autre site n'expose personne tant que
# l'usager ne clique pas.
RESSOURCE = re.compile(
    r"""<(?:script|img|iframe|link|source|video|audio|embed)\b[^>]*?"""
    r"""\b(?:src|href)\s*=\s*["']([^"']+)["']""",
    re.I | re.S)


def racine(hote: str) -> str:
    """Domaine enregistrable, approche par les deux derniers labels.

    ⛔ Approximation assumee : elle range `www.paris.fr` et `paris.fr` ensemble,
    ce qu'on veut, mais elle traite `xxx.gouv.fr` comme `gouv.fr`. Les suffixes
    a deux niveaux frequents en France sont donc listes explicitement, sinon
    tous les sites d'Etat seraient comptes comme un seul tiers.
    """
    h = hote.lower().strip(".")
    parts = h.split(".")
    if len(parts) <= 2:
        return h
    doubles = {"gouv.fr", "asso.fr", "com.fr", "co.uk", "org.uk", "gov.uk"}
    if ".".join(parts[-2:]) in doubles and len(parts) >= 3:
        return ".".join(parts[-3:])
    return ".".join(parts[-2:])


def lire(url: str):
    req = urllib.request.Request(url, headers={
        "User-Agent": UA,
        "Accept": "text/html,application/xhtml+xml",
        "Accept-Encoding": "gzip",
    })
    with urllib.request.urlopen(req, timeout=DELAI) as r:
        brut = r.read(600_000)
        if (r.headers.get("Content-Encoding") or "").lower() == "gzip":
            try:
                brut = gzip.decompress(brut)
            except Exception:
                pass
        return r.status, dict(r.headers), brut.decode("utf-8", "replace"), r.geturl()


def sonde(domaine: str) -> dict:
    d = {"domaine": domaine, "https_repond": 0, "statut": "",
         "referrer_policy": "", "tiers_distincts": 0, "tiers_liste": "",
         "cookie_pose": 0, "erreur": ""}
    try:
        statut, entetes, html, finale = lire("https://" + domaine + "/")
    except urllib.error.HTTPError as e:
        d["statut"] = e.code
        d["erreur"] = "HTTPError"
        return d
    except Exception as e:
        d["erreur"] = type(e).__name__
        return d

    d["https_repond"] = 1
    d["statut"] = statut
    d["referrer_policy"] = (entetes.get("Referrer-Policy") or "").strip().lower()
    d["cookie_pose"] = 1 if entetes.get("Set-Cookie") else 0

    propre = racine(urlparse(finale).hostname or domaine)
    tiers = set()
    for ref in RESSOURCE.findall(html):
        ref = ref.strip()
        if ref.startswith("//"):
            ref = "https:" + ref
        if not ref.lower().startswith(("http://", "https://")):
            continue  # relatif = meme site, rien a compter
        h = urlparse(ref).hostname
        if not h:
            continue
        r = racine(h)
        if r and r != propre:
            tiers.add(r)
    d["tiers_distincts"] = len(tiers)
    d["tiers_liste"] = " ".join(sorted(tiers)[:12])
    return d


def main() -> int:
    if len(sys.argv) < 3:
        print(__doc__)
        return 2
    source, sortie = sys.argv[1], sys.argv[2]
    taille = int(sys.argv[3]) if len(sys.argv) > 3 else 0

    with open(source, encoding="utf-8") as fh:
        domaines = [l["domaine"] for l in csv.DictReader(fh) if l.get("domaine")]
    if taille:
        domaines = domaines[:taille]
    print("%d domaines a sonder" % len(domaines), file=sys.stderr)

    lignes = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
        for i, r in enumerate(ex.map(sonde, domaines), 1):
            lignes.append(r)
            if i % 100 == 0:
                print("  %d/%d" % (i, len(domaines)), file=sys.stderr)

    champs = ["domaine", "https_repond", "statut", "referrer_policy",
              "tiers_distincts", "tiers_liste", "cookie_pose", "erreur"]
    os.makedirs(os.path.dirname(sortie) or ".", exist_ok=True)
    with open(sortie, "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=champs)
        w.writeheader()
        for l in lignes:
            w.writerow(l)

    repondants = [l for l in lignes if l["https_repond"]]
    n = len(repondants)
    # ⛔ Tous les taux se calculent sur les REPONDANTS, jamais sur l'echantillon
    # entier : un domaine muet n'est pas un domaine sans tiers.
    synth = {
        "date_releve": date.today().isoformat(),
        "domaines_sondes": len(lignes),
        "domaines_repondants": n,
        "sans_aucun_tiers": sum(1 for l in repondants if l["tiers_distincts"] == 0),
        "avec_referrer_policy": sum(1 for l in repondants if l["referrer_policy"]),
        "pose_un_cookie_des_l_accueil": sum(1 for l in repondants if l["cookie_pose"]),
        "tiers_moyen": round(sum(l["tiers_distincts"] for l in repondants) / n, 2) if n else None,
        "tiers_median": sorted(l["tiers_distincts"] for l in repondants)[n // 2] if n else None,
    }
    compte = Counter()
    for l in repondants:
        compte.update(l["tiers_liste"].split())
    synth["tiers_les_plus_frequents"] = compte.most_common(15)

    js = sortie.rsplit(".", 1)[0] + "-synthese.json"
    with open(js, "w", encoding="utf-8") as fh:
        json.dump(synth, fh, ensure_ascii=False, indent=2)

    print(json.dumps(synth, ensure_ascii=False, indent=2))
    print("\n%s\n%s" % (sortie, js), file=sys.stderr)
    return 0


if __name__ == "__main__":
    sys.exit(main())
