#!/usr/bin/env python3 # ═══════════════════════════════════════════════════════════════════════════ # SONDE D'ACCESSIBILITÉ — ce qui se vérifie sur le HTML servi, et rien de plus # ═══════════════════════════════════════════════════════════════════════════ # # Produit TEMPOsystem. ⚠️ SÉPARÉE de `sonde-seuil.py`, qui appartient à la # mesure associative d'Alliance MANA : même méthode, deux populations, deux # artefacts. Cette séparation est fiscale autant que morale — voir le plan. # # 🔴 TROIS ÉTATS, JAMAIS DEUX. Chaque critère rend : # C conforme — vérifié, et ça passe # NC non conforme — vérifié, et ça ne passe pas # NT NON TESTABLE — l'instrument ne sait pas voir cela # # ⭐ Le troisième état est le produit. Un outil qui rend « conforme » là où il # n'a rien pu lire vend une certitude qu'il n'a pas. Le RGAA lui-même prévoit # « Non Applicable » ; nous ajoutons « Non Testable », qui dit quelque chose sur # NOUS et pas sur le site — et c'est précisément ce qu'un avocat veut savoir # avant d'opposer un rapport. # # ⚠️ CE QUE CETTE SONDE NE VERRA JAMAIS, et qui est déclaré dans chaque dossier : # · le CONTRASTE réel — il demande le rendu, donc un navigateur ; # · l'ORDRE DE TABULATION effectif, et le piège au clavier ; # · tout ce qu'un JavaScript injecte après le chargement ; # · la PERTINENCE d'un texte alternatif ou d'un intitulé de lien — c'est du # SENS, et c'est là, et seulement là, qu'un modèle de langage intervient ; # · le comportement réel sous lecteur d'écran. # Ces cinq lignes valent un argument commercial : nous mesurons un PLANCHER et # nous le disons. Un concurrent qui annonce un taux de conformité depuis le seul # HTML annonce un chiffre qu'il ne peut pas tenir. # # Usage : python3 sonde-a11y.py domaines.csv > a11y.csv # entrée : une colonne `domaine` (les autres colonnes sont conservées) import csv, re, ssl, sys, gzip, socket from collections import Counter from concurrent.futures import ThreadPoolExecutor from html.parser import HTMLParser from urllib.request import Request, urlopen AGENT = ("Mozilla/5.0 (compatible; TEMPOsystem-a11y/1.0; +https://temposystem.eu/ ; " "contact@temposystem.eu) mesure d'accessibilite") DELAI = 20 PARALLELE = 4 # Les types de champ qui n'appellent pas d'étiquette visible. SANS_ETIQUETTE = {"hidden", "submit", "button", "reset", "image"} import unicodedata def _sans_accent(t): return "".join(c for c in unicodedata.normalize("NFD", t.lower()) if unicodedata.category(c) != "Mn") # ⭐ LA MENTION EST IMPOSÉE PAR L'ARRÊTÉ DU 20 SEPTEMBRE 2019 : toute page doit # porter « Accessibilité : totalement / partiellement / non conforme ». C'est # une DÉCLARATION FAITE PAR LE SITE LUI-MÊME. Nous la lisons telle quelle — et # c'est le seul endroit où nous n'avons rien à prouver : c'est lui qui parle. MENTION = re.compile(r"accessibilite\s*:?\s*(totalement|partiellement|non)\s+conforme") # Les adresses où une déclaration se range quand elle n'est pas en page d'accueil. # ⚠️ On ne les interroge QUE si la page d'accueil n'a rien dit : une requête de # plus par site sondé, jamais six. CHEMINS_USUELS = ["/accessibilite", "/accessibilite.html", "/declaration-accessibilite", "/fr/accessibilite", "/mentions-legales"] class Page(HTMLParser): """Un seul passage sur le document. On collecte des FAITS, on ne juge pas ici.""" def __init__(self): super().__init__(convert_charrefs=True) self.lang = None self.titre = "" self._dans_titre = False self.img_total = 0 self.img_sans_alt = 0 self.iframes = 0 self.iframes_sans_titre = 0 self.iframes_masquees = 0 self._dans_noscript = 0 self.liens = 0 self.liens_muets = 0 self.boutons = 0 self.boutons_muets = 0 self.champs = 0 self.champs_sans_etiquette = 0 self.titres = [] # niveaux rencontrés, dans l'ordre self.tables = 0 self.tables_sans_th = 0 self._table_a_th = [] self.ids = Counter() self.tabindex_positif = 0 self.autoplay = 0 self.labels_for = set() self.liens_accessibilite = 0 self.chemins_accessibilite = [] # les cibles trouvées, pour le dossier self.texte_corps = [] # ce que la page dit sans JavaScript self._dans_a = 0 # un élément « muet » peut recevoir du texte ensuite : on garde la pile self._pile_lien = [] self._pile_bouton = [] self._champs_en_attente = [] # (id, a_une_etiquette_intrinseque) self._dans_label = 0 # ── collecte ────────────────────────────────────────────────────────── def handle_starttag(self, tag, attrs): a = {k.lower(): (v or "") for k, v in attrs} if tag == "html": self.lang = a.get("lang") or a.get("xml:lang") elif tag == "title": self._dans_titre = True elif tag == "img": self.img_total += 1 # 🔴 FAUTE DE LA v1, TROUVÉE SUR ameli.fr. Un lien qui ne contient # qu'une image porte son intitulé dans l'`alt` de cette image, pas # dans du texte. Ne compter que le texte accusait de « lien muet » # des liens parfaitement accessibles — et un rapport qui accuse à # tort se retourne entièrement contre celui qui le signe. if (a.get("alt") or "").strip(): if self._pile_lien: self._pile_lien[-1] = True if self._pile_bouton: self._pile_bouton[-1] = True # ⚠️ `alt=""` est VALIDE : il déclare une image décorative. L'absence # d'attribut, elle, est une faute. Confondre les deux ferait un # rapport faux dans le sens le plus coûteux — celui qui accuse. if "alt" not in a: self.img_sans_alt += 1 elif tag == "iframe": # ⚠️ Celle de Google Tag Manager vit dans un