@rgaaudit/mcp-server 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @rgaaudit/mcp-server
2
2
 
3
- Serveur MCP (Model Context Protocol) pour auditer l'accessibilite web selon le referentiel francais **RGAA 4.1**, directement depuis votre IDE.
3
+ Serveur MCP (Model Context Protocol) pour auditer l'accessibilité web selon le référentiel français **RGAA 4.1**, directement depuis votre IDE.
4
4
 
5
5
  Compatible **Claude Code**, **Cursor**, **Copilot**, **Windsurf**.
6
6
 
@@ -12,15 +12,16 @@ npx @rgaaudit/mcp-server
12
12
 
13
13
  ## Configuration
14
14
 
15
- ### 1. Obtenir une cle API
15
+ ### 1. Obtenir une clé API
16
16
 
17
- 1. Creez un compte sur [rgaaudit.fr](https://rgaaudit.fr)
18
- 2. Allez dans **Parametres > API**
19
- 3. Generez une cle API (plan Freelance ou superieur)
17
+ 1. Créez un compte sur [rgaaudit.fr](https://rgaaudit.fr)
18
+ 2. Choisissez un plan **Freelance** ou supérieur (le plan Découverte ne donne pas accès à l'API)
19
+ 3. Allez dans **Paramètres > API**
20
+ 4. Générez une clé API (préfixe `rga_`)
20
21
 
21
22
  ### 2. Configurer Claude Code
22
23
 
23
- Ajoutez dans `.mcp.json` a la racine de votre projet :
24
+ Ajoutez dans `.mcp.json` à la racine de votre projet :
24
25
 
25
26
  ```json
26
27
  {
@@ -38,35 +39,35 @@ Ajoutez dans `.mcp.json` a la racine de votre projet :
38
39
 
39
40
  ### 3. Configurer Cursor / Copilot
40
41
 
41
- Ajoutez la meme configuration dans les parametres MCP de votre IDE.
42
+ Ajoutez la même configuration dans les paramètres MCP de votre IDE.
42
43
 
43
44
  ## Outils disponibles
44
45
 
45
46
  | Outil | Description |
46
47
  |-------|-------------|
47
48
  | `rgaa_audit` | Audit complet RGAA 4.1 avec violations, patterns et suggestions |
48
- | `rgaa_score` | Score de conformite rapide (0-100%) |
49
- | `rgaa_check` | Verifier un critere RGAA specifique (ex: 1.1, 3.2, 11.1) |
50
- | `rgaa_explain` | Expliquer un critere RGAA avec exemples de code |
51
- | `rgaa_report` | Rapport JSON structure pour CI/CD |
49
+ | `rgaa_score` | Score de conformité rapide (0-100%) |
50
+ | `rgaa_check` | Vérifier un critère RGAA spécifique (ex: 1.1, 3.2, 11.1) |
51
+ | `rgaa_explain` | Expliquer un critère RGAA avec exemples de code |
52
+ | `rgaa_report` | Rapport JSON structuré pour CI/CD |
52
53
 
53
54
  ## Exemples d'utilisation
54
55
 
55
56
  Dans Claude Code, demandez simplement :
56
57
 
57
58
  ```
58
- > Audite l'accessibilite de https://monsite.fr
59
+ > Audite l'accessibilité de https://monsite.fr
59
60
  > Quel est le score RGAA de https://monsite.fr ?
60
- > Verifie le critere 3.2 (contrastes) sur https://monsite.fr
61
- > Explique le critere RGAA 11.1
61
+ > Vérifie le critère 3.2 (contrastes) sur https://monsite.fr
62
+ > Explique le critère RGAA 11.1
62
63
  ```
63
64
 
64
65
  ## Variables d'environnement
65
66
 
66
67
  | Variable | Requis | Description |
67
68
  |----------|--------|-------------|
68
- | `RGAA_API_KEY` | Oui | Cle API RGAAudit (format `rga_...`) |
69
- | `RGAA_API_URL` | Non | URL de l'API (defaut: `https://app.rgaaudit.fr`) |
69
+ | `RGAA_API_KEY` | Oui | Clé API RGAAudit (format `rga_...`) |
70
+ | `RGAA_API_URL` | Non | URL de l'API (défaut: `https://rgaaudit.fr`) |
70
71
 
71
72
  ## Licence
72
73
 
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Configuration requise :
9
9
  * RGAA_API_KEY — Clé API (générée dans Paramètres > API)
10
- * RGAA_API_URL — URL de l'API (optionnel, défaut: https://app.rgaaudit.fr)
10
+ * RGAA_API_URL — URL de l'API (optionnel, défaut: https://rgaaudit.fr)
11
11
  *
12
12
  * Outils disponibles :
13
13
  * - rgaa_audit : Lancer un audit complet sur une URL
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Configuration requise :
9
9
  * RGAA_API_KEY — Clé API (générée dans Paramètres > API)
10
- * RGAA_API_URL — URL de l'API (optionnel, défaut: https://app.rgaaudit.fr)
10
+ * RGAA_API_URL — URL de l'API (optionnel, défaut: https://rgaaudit.fr)
11
11
  *
12
12
  * Outils disponibles :
13
13
  * - rgaa_audit : Lancer un audit complet sur une URL
@@ -19,12 +19,11 @@
19
19
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
20
20
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21
21
  import { z } from 'zod';
22
- import { isLocalUrl, scanLocal, fetchRgaaMappings } from './local-scanner.js';
23
- const API_URL = process.env.RGAA_API_URL || 'https://app.rgaaudit.fr';
22
+ const API_URL = process.env.RGAA_API_URL || 'https://rgaaudit.fr';
24
23
  const API_KEY = process.env.RGAA_API_KEY || '';
25
24
  const server = new McpServer({
26
25
  name: 'rgaaudit',
27
- version: '0.3.0',
26
+ version: '0.3.1',
28
27
  });
29
28
  // ── Cache d'audit (TTL 10 min, évite double consommation de quota) ──
30
29
  const CACHE_TTL_MS = 10 * 60 * 1000;
@@ -40,107 +39,79 @@ function cleanCache() {
40
39
  }
41
40
  }
42
41
  /**
43
- * Scan local pour URLs localhost utilise puppeteer-core + axe-core.
44
- * Enrichit les résultats avec le mapping RGAA via /api/ci/map si l'API est disponible.
45
- *
46
- * @param url - URL locale à scanner
47
- * @param options - details pour inclure les nœuds HTML
48
- */
49
- async function callLocalScan(url, options) {
50
- const localResult = await scanLocal(url);
51
- // Enrichir avec le mapping RGAA si API disponible
52
- const ruleIds = localResult.violations.map(v => v.ruleId);
53
- const mapping = API_KEY ? await fetchRgaaMappings(ruleIds, API_URL, API_KEY) : null;
54
- const violations = localResult.violations;
55
- const score = violations.length === 0 ? 100 : Math.max(0, Math.round(100 - (violations.length * 3)));
56
- const conformity = score >= 100 ? 'conforme' : score >= 50 ? 'partiellement_conforme' : 'non_conforme';
57
- const threshold = options?.threshold ?? 0;
58
- const result = {
59
- url,
60
- score,
61
- conformity,
62
- violations: violations.length,
63
- pages: 1,
64
- pass: threshold > 0 ? score >= threshold : true,
65
- threshold: threshold > 0 ? threshold : null,
66
- auditId: `local-${Date.now()}`,
67
- auditUrl: url,
68
- coverage: mapping?.coverage,
69
- };
70
- if (options?.details) {
71
- result.violationDetails = violations.map(v => {
72
- const m = mapping?.mappings[v.ruleId];
73
- return {
74
- ruleId: v.ruleId,
75
- criteria: m?.criteria || null,
76
- criteriaTitle: m?.title || null,
77
- theme: m?.theme || null,
78
- description: m?.help || v.description,
79
- impact: m?.impact || v.impact,
80
- nodesCount: v.nodesCount,
81
- zone: null,
82
- suggestion: m?.help || v.help,
83
- nodes: v.nodes,
84
- };
85
- });
86
- }
87
- return result;
88
- }
89
- /**
90
- * Appelle POST /api/ci/scan (distant) ou scanLocal (localhost).
91
- * Cache intelligent : réutilise l'auditId si un audit récent existe.
42
+ * Appelle POST /api/ci/scan toujours via l'API (règles custom + patterns + mapping RGAA).
43
+ * Cache intelligent : réutilise l'auditId si un audit récent existe (0 quota).
92
44
  *
93
45
  * @param url - URL à auditer
94
46
  * @param options - details, threshold, auditId (pour réutiliser un audit existant)
95
47
  */
96
48
  async function callScan(url, options) {
49
+ if (!API_KEY) {
50
+ throw new Error('RGAA_API_KEY non configurée.\n\n' +
51
+ '1. Créez un compte sur https://rgaaudit.fr\n' +
52
+ '2. Allez dans Paramètres > API\n' +
53
+ '3. Générez une clé API\n' +
54
+ '4. Configurez : RGAA_API_KEY=rga_...');
55
+ }
97
56
  cleanCache();
98
- // Vérifier le cache
57
+ // Vérifier le cache : si un audit récent existe, réutiliser l'auditId (0 quota)
99
58
  const cacheKey = url.toLowerCase().replace(/\/+$/, '');
100
59
  const cached = auditCache.get(cacheKey);
101
60
  if (cached && !options?.auditId) {
102
- // Cache hit — retourner directement si on a déjà les détails ou si on n'en demande pas
103
61
  if (!options?.details || cached.result.violationDetails) {
104
62
  return cached.result;
105
63
  }
106
64
  }
107
- // Route : localhost scan local, sinon → API distante
108
- let result;
109
- if (isLocalUrl(url)) {
110
- result = await callLocalScan(url, options);
111
- }
112
- else {
113
- if (!API_KEY) {
114
- throw new Error('RGAA_API_KEY non configurée.\n\n' +
115
- '1. Créez un compte sur https://app.rgaaudit.fr\n' +
116
- '2. Allez dans Paramètres > API\n' +
117
- '3. Générez une clé API\n' +
118
- '4. Configurez : RGAA_API_KEY=rga_...');
119
- }
120
- const auditId = options?.auditId || cached?.result.auditId;
121
- const res = await fetch(`${API_URL}/api/ci/scan`, {
122
- method: 'POST',
123
- headers: {
124
- 'Content-Type': 'application/json',
125
- 'X-API-Key': API_KEY,
126
- },
127
- body: JSON.stringify({
128
- url,
129
- details: options?.details ?? false,
130
- threshold: options?.threshold ?? 0,
131
- ...(auditId ? { auditId } : {}),
132
- }),
133
- });
134
- if (!res.ok) {
135
- const body = await res.json().catch(() => ({}));
136
- throw new Error(`API erreur ${res.status}: ${body.message || res.statusText}`);
137
- }
138
- result = await res.json();
65
+ const auditId = options?.auditId || cached?.result.auditId;
66
+ const res = await fetch(`${API_URL}/api/ci/scan`, {
67
+ method: 'POST',
68
+ headers: {
69
+ 'Content-Type': 'application/json',
70
+ 'X-API-Key': API_KEY,
71
+ 'X-Source': 'mcp',
72
+ },
73
+ body: JSON.stringify({
74
+ url,
75
+ details: options?.details ?? false,
76
+ threshold: options?.threshold ?? 0,
77
+ ...(auditId ? { auditId } : {}),
78
+ }),
79
+ });
80
+ if (!res.ok) {
81
+ const body = await res.json().catch(() => ({}));
82
+ throw new Error(`API erreur ${res.status}: ${body.message || res.statusText}`);
139
83
  }
84
+ const result = await res.json();
140
85
  // Mettre en cache
141
86
  auditCache.set(cacheKey, { result, timestamp: Date.now() });
142
87
  return result;
143
88
  }
89
+ /**
90
+ * Appelle POST /api/ci/recheck — re-scanne les mêmes pages d'un audit existant.
91
+ * Smart re-audit : pas de discovery, pas de crawl. Consomme 1 token.
92
+ * Retourne le delta (score, violations résolues/nouvelles).
93
+ *
94
+ * @param auditId - ID de l'audit à re-checker
95
+ */
96
+ async function callRecheck(auditId) {
97
+ if (!API_KEY) {
98
+ throw new Error('RGAA_API_KEY non configurée.');
99
+ }
100
+ const res = await fetch(`${API_URL}/api/ci/recheck`, {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ 'X-API-Key': API_KEY,
105
+ 'X-Source': 'mcp',
106
+ },
107
+ body: JSON.stringify({ auditId }),
108
+ });
109
+ if (!res.ok) {
110
+ const body = await res.json().catch(() => ({}));
111
+ throw new Error(`API erreur ${res.status}: ${body.message || res.statusText}`);
112
+ }
113
+ return res.json();
114
+ }
144
115
  // ── Outil 1 : rgaa_audit ──
145
116
  server.tool('rgaa_audit', 'Lancer un audit d\'accessibilité RGAA 4.1 complet sur une URL. Retourne les violations avec critères RGAA, impact, et suggestions de correction.', {
146
117
  url: z.string().url().describe('URL du site web à auditer'),
@@ -148,48 +119,126 @@ server.tool('rgaa_audit', 'Lancer un audit d\'accessibilité RGAA 4.1 complet su
148
119
  try {
149
120
  const result = await callScan(url, { details: true });
150
121
  const violations = result.violationDetails || [];
151
- const critique = violations.filter(v => v.impact === 'critical').length;
152
- const serieux = violations.filter(v => v.impact === 'serious').length;
153
- const modere = violations.filter(v => v.impact === 'moderate').length;
154
- const uniqueViolations = result.violationsInSharedZones
155
- ? result.violations - result.violationsInSharedZones
156
- : result.violations;
122
+ const impactLabels = { critical: 'critique', serious: 'sérieux', moderate: 'modéré', minor: 'mineur', critique: 'critique', serieux: 'sérieux', modere: 'modéré' };
123
+ const impactOrder = { critical: 0, critique: 0, serious: 1, serieux: 1, moderate: 2, modere: 2, minor: 3, mineur: 3 };
124
+ const bySeverity = {};
125
+ for (const v of violations) {
126
+ const label = impactLabels[v.impact] || v.impact;
127
+ bySeverity[label] = (bySeverity[label] || 0) + 1;
128
+ }
157
129
  const sharedInfo = result.violationsInSharedZones
158
130
  ? ` (dont ${result.violationsInSharedZones} dans des zones partagées nav/header/footer)`
159
131
  : '';
160
- let text = `# Audit RGAA 4.1 — ${url}\n\n` +
161
- `**Score** : ${result.score}% — ${result.conformity}\n` +
162
- `**${result.violations} non-conformités**${sharedInfo} sur ${result.pages} pages\n` +
163
- `- ${critique} critique(s), ${serieux} sérieuse(s), ${modere} modérée(s)\n`;
132
+ // ── En-tête ──
133
+ let text = `# Audit RGAA 4.1 — ${url}\n\n`;
134
+ text += `| Métrique | Valeur |\n|----------|--------|\n`;
135
+ text += `| Score | **${result.score}%** ${result.conformity} |\n`;
136
+ text += `| Non-conformités | ${result.violations}${sharedInfo} |\n`;
137
+ text += `| Par impact | ${Object.entries(bySeverity).map(([k, v]) => `${v} ${k}`).join(', ') || 'aucune'} |\n`;
138
+ text += `| Pages analysées | ${result.pages} auditées |\n`;
164
139
  if (result.coverage) {
165
- text += `- **Couverture RGAA** : ${result.coverage.percentage}% (${result.coverage.mappedCriteria}/${result.coverage.totalCriteria} critères testés)\n`;
140
+ text += `| Couverture RGAA | ${result.coverage.percentage}% (${result.coverage.mappedCriteria}/${result.coverage.totalCriteria} critères) |\n`;
141
+ }
142
+ if (result.techStack?.framework || result.techStack?.cms) {
143
+ text += `| Technologies | ${[result.techStack.framework, result.techStack.cms].filter(Boolean).join(', ')} |\n`;
166
144
  }
145
+ text += `| Source | ${result.source || 'ci'} |\n`;
146
+ text += `| Audit ID | \`${result.auditId}\` |\n`;
167
147
  text += '\n';
168
- if (result.patterns && result.patterns.length > 0) {
169
- text += `## Patterns de correction (${result.patterns.length})\n\n`;
170
- for (const p of result.patterns) {
171
- text += `- **${p.title}** (${p.type}) ${p.violationsCount} violations, ${p.pagesAffected} pages\n`;
148
+ // ── Pages auditées ──
149
+ if (result.pagesAudited && result.pagesAudited.length > 0) {
150
+ const audited = result.pagesAudited.filter(p => p.status === 'COMPLETED');
151
+ const extrapolated = result.pagesAudited.filter(p => p.status === 'EXTRAPOLATED');
152
+ const errors = result.pagesAudited.filter(p => !['COMPLETED', 'EXTRAPOLATED'].includes(p.status));
153
+ text += `## Pages (${result.pagesAudited.length} total : ${audited.length} auditées, ${extrapolated.length} extrapolées${errors.length ? `, ${errors.length} erreurs` : ''})\n\n`;
154
+ if (audited.length > 0) {
155
+ text += `### Pages auditées (axe-core)\n`;
156
+ for (const p of audited) {
157
+ text += `- **${p.title || p.url}** — ${p.url}${p.score != null ? ` — **${p.score}%**` : ''}\n`;
158
+ }
159
+ text += '\n';
160
+ }
161
+ if (extrapolated.length > 0) {
162
+ text += `### Pages extrapolées (Template Detection — mêmes violations que la page source)\n`;
163
+ for (const p of extrapolated) {
164
+ text += `- ${p.title || p.url} — ${p.url}\n`;
165
+ }
166
+ text += '\n';
172
167
  }
173
- text += '\n';
174
168
  }
169
+ // ── Violations ──
175
170
  if (violations.length > 0) {
176
- text += `## Détail des violations\n\n`;
177
- for (const [i, v] of violations.entries()) {
178
- text += `### ${i + 1}. ${v.criteriaTitle || v.description}\n`;
179
- text += `- **Critère RGAA** : ${v.criteria || 'N/A'}${v.theme ? ` (${v.theme})` : ''}\n`;
180
- text += `- **Impact** : ${v.impact}\n`;
181
- text += `- **Éléments** : ${v.nodesCount} nœuds\n`;
182
- if (v.zone)
183
- text += `- **Zone** : ${v.zone}\n`;
184
- if (v.suggestion)
185
- text += `- **Suggestion** : ${v.suggestion}\n`;
186
- if (v.nodes.length > 0) {
187
- text += `- **Exemple** : \`${v.nodes[0].target}\`\n`;
171
+ const real = violations.filter(v => !v.isExtrapolated);
172
+ const extrapolatedV = violations.filter(v => v.isExtrapolated);
173
+ // Trier par impact (critique d'abord)
174
+ real.sort((a, b) => (impactOrder[a.impact] ?? 4) - (impactOrder[b.impact] ?? 4));
175
+ text += `## Non-conformités (${real.length} réelles${extrapolatedV.length > 0 ? `, ${extrapolatedV.length} extrapolées` : ''})\n\n`;
176
+ for (const [i, v] of real.entries()) {
177
+ const impact = impactLabels[v.impact] || v.impact;
178
+ text += `### ${i + 1}. [${impact.toUpperCase()}] ${v.criteria || ''} — ${v.criteriaTitle || v.description}\n\n`;
179
+ text += `| | |\n|---|---|\n`;
180
+ text += `| Critère RGAA | ${v.criteria || 'N/A'} |\n`;
181
+ if (v.theme)
182
+ text += `| Thématique | ${v.theme} |\n`;
183
+ text += `| Impact | ${impact} |\n`;
184
+ text += `| Nœuds affectés | ${v.nodesCount} |\n`;
185
+ if (v.zone) {
186
+ const isShared = ['header', 'nav', 'footer'].includes(v.zone);
187
+ text += `| Zone | ${v.zone}${isShared ? ' **(partagée — corrigez le layout, pas chaque page)**' : ''} |\n`;
188
+ }
189
+ if (v.pageUrls && v.pageUrls.length > 0) {
190
+ text += `| Pages | ${v.pageUrls.join(', ')} |\n`;
191
+ }
192
+ text += '\n';
193
+ // Suggestion de correction
194
+ if (v.suggestion) {
195
+ text += `**Suggestion** : ${v.suggestion}\n\n`;
196
+ }
197
+ // Nœuds détaillés avec HTML
198
+ text += `**Nœuds affectés :**\n\n`;
199
+ for (const n of v.nodes.slice(0, 5)) {
200
+ const selector = n.contextPath || n.target;
201
+ text += `- **Sélecteur** : \`${selector}\`\n`;
202
+ if (n.pageUrl)
203
+ text += ` - Page : ${n.pageUrl}\n`;
204
+ if (n.html)
205
+ text += ` - HTML : \`${n.html.replace(/\n/g, ' ').trim()}\`\n`;
206
+ if (n.failureSummary)
207
+ text += ` - Problème : ${n.failureSummary}\n`;
208
+ if (n.zone)
209
+ text += ` - Zone : ${n.zone}\n`;
210
+ }
211
+ if (v.nodes.length > 5)
212
+ text += `- ... et ${v.nodes.length - 5} autres nœuds\n`;
213
+ text += '\n';
214
+ }
215
+ if (extrapolatedV.length > 0) {
216
+ text += `## Violations extrapolées (${extrapolatedV.length})\n\n`;
217
+ text += `> Template Detection : ces violations sont héritées d'une page source. Corrigez la page source, toutes les pages extrapolées seront corrigées.\n\n`;
218
+ for (const v of extrapolatedV) {
219
+ const impact = impactLabels[v.impact] || v.impact;
220
+ text += `- **[${impact}] ${v.criteria || ''}** ${v.criteriaTitle || v.description} — ${v.nodesCount} nœuds`;
221
+ if (v.pageUrls && v.pageUrls.length > 0)
222
+ text += ` (${v.pageUrls.join(', ')})`;
223
+ text += '\n';
188
224
  }
189
225
  text += '\n';
190
226
  }
191
227
  }
192
- text += `\n🔗 Rapport complet : ${result.auditUrl}`;
228
+ // ── Patterns / Corrections groupées ──
229
+ if (result.patterns && result.patterns.length > 0) {
230
+ text += `## Patterns de correction groupée (${result.patterns.length})\n\n`;
231
+ text += `> Un pattern = plusieurs nœuds identiques corrigeables en une seule modification.\n\n`;
232
+ text += `| Pattern | Type | Occurrences |\n|---------|------|-------------|\n`;
233
+ for (const p of result.patterns) {
234
+ text += `| ${p.title} | ${p.type} | ${p.occurrences} |\n`;
235
+ }
236
+ text += '\n';
237
+ }
238
+ // ── Lien rapport ──
239
+ text += `---\n\n`;
240
+ text += `**Rapport complet** : ${result.auditUrl}\n`;
241
+ text += `**Audit ID** : \`${result.auditId}\` (utilisable avec rgaa_recheck pour mesurer les améliorations)\n`;
193
242
  return { content: [{ type: 'text', text }] };
194
243
  }
195
244
  catch (error) {
@@ -431,7 +480,6 @@ server.tool('rgaa_remediate', 'Plan de remédiation autonome : audite, priorise
431
480
  try {
432
481
  const result = await callScan(url, { details: true });
433
482
  const violations = result.violationDetails || [];
434
- const isLocal = isLocalUrl(url);
435
483
  if (violations.length === 0) {
436
484
  return {
437
485
  content: [{
@@ -483,19 +531,14 @@ server.tool('rgaa_remediate', 'Plan de remédiation autonome : audite, priorise
483
531
  text += `| Corrigeables auto | ${fixable.length} |\n`;
484
532
  text += `| Manuelles | ${manual.length} |\n`;
485
533
  text += `| Fixes dans ce plan | ${toFix.length} |\n`;
486
- if (isLocal) {
487
- text += `| Mode | **Localhost** (re-audits gratuits) |\n`;
488
- }
489
- else {
490
- text += `| Mode | **Distant** (re-audit = 1 quota) |\n`;
491
- }
534
+ text += `| Re-check | 1 token (smart re-audit, pas de re-discovery) |\n`;
492
535
  text += '\n';
493
536
  // Score estimé après corrections
494
537
  const estimatedGain = Math.min(30, toFix.length * 3);
495
538
  const estimatedScore = Math.min(100, result.score + estimatedGain);
496
539
  text += `**Score estimé après corrections** : ~${estimatedScore}% (+${estimatedGain} points)\n\n`;
497
540
  text += `---\n\n## Corrections à appliquer (${toFix.length})\n\n`;
498
- text += `Appliquez chaque fix dans l'ordre. Après le dernier fix, lancez \`rgaa_score\` pour mesurer l'amélioration.\n\n`;
541
+ text += `Appliquez chaque fix dans l'ordre. Après le dernier fix, lancez \`rgaa_recheck("${result.auditId}")\` pour mesurer le delta.\n\n`;
499
542
  for (const [i, v] of toFix.entries()) {
500
543
  const fix = RGAA_FIXES[v.ruleId];
501
544
  const code = fix.fixes[framework] || fix.fixes['html'];
@@ -503,8 +546,12 @@ server.tool('rgaa_remediate', 'Plan de remédiation autonome : audite, priorise
503
546
  text += `- **Problème** : ${fix.description}\n`;
504
547
  text += `- **Impact** : ${v.impact}\n`;
505
548
  text += `- **Éléments** : ${v.nodesCount} nœuds affectés\n`;
506
- if (v.zone)
549
+ if (v.zone && ['header', 'nav', 'footer'].includes(v.zone)) {
550
+ text += `- **Zone partagée** : ${v.zone} → corrigez le composant layout (1 fix = toutes les pages)\n`;
551
+ }
552
+ else if (v.zone) {
507
553
  text += `- **Zone** : ${v.zone}\n`;
554
+ }
508
555
  // Exemples de nœuds à corriger
509
556
  if (v.nodes.length > 0) {
510
557
  text += `- **Éléments à corriger** :\n`;
@@ -537,13 +584,7 @@ server.tool('rgaa_remediate', 'Plan de remédiation autonome : audite, priorise
537
584
  text += `1. Pour chaque fix ci-dessus, identifiez le fichier source concerné\n`;
538
585
  text += `2. Appliquez le code correctif adapté au composant\n`;
539
586
  text += `3. Après toutes les corrections, exécutez :\n`;
540
- text += ` - \`rgaa_score("${url}")\` pour mesurer l'amélioration\n`;
541
- if (isLocal) {
542
- text += ` - Re-audits **gratuits** (scan local, pas de quota)\n`;
543
- }
544
- else {
545
- text += ` - Chaque re-audit **consomme 1 crédit** du quota utilisateur\n`;
546
- }
587
+ text += ` - \`rgaa_recheck("${result.auditId}")\` pour mesurer le delta (1 token)\n`;
547
588
  text += `4. Si le score < ${targetScore}%, relancez \`rgaa_remediate\` pour le cycle suivant\n`;
548
589
  text += `5. Vérifiez visuellement avec \`browser_screenshot\` que le rendu n'est pas cassé\n`;
549
590
  return { content: [{ type: 'text', text }] };
@@ -552,7 +593,168 @@ server.tool('rgaa_remediate', 'Plan de remédiation autonome : audite, priorise
552
593
  return { content: [{ type: 'text', text: `Erreur : ${error instanceof Error ? error.message : String(error)}` }], isError: true };
553
594
  }
554
595
  });
555
- // ── Outil 7 : rgaa_report ──
596
+ // ── Outil 7 : rgaa_recheck ──
597
+ server.tool('rgaa_recheck', 'Re-check rapide : re-scanne les mêmes pages d\'un audit existant sans redécouvrir le site. Retourne le delta (score avant/après, violations résolues/nouvelles). Consomme 1 token du plan. Utilisez après avoir corrigé des violations pour mesurer l\'amélioration.', {
598
+ auditId: z.string().describe('ID de l\'audit à re-checker (retourné par rgaa_audit ou rgaa_report)'),
599
+ }, async ({ auditId }) => {
600
+ try {
601
+ const result = await callRecheck(auditId);
602
+ const deltaSign = result.score > result.previousScore ? '+' : '';
603
+ const violFixedText = result.violationsDelta > 0
604
+ ? `${result.violationsDelta} violation(s) corrigée(s)`
605
+ : result.violationsDelta < 0
606
+ ? `${Math.abs(result.violationsDelta)} nouvelle(s) violation(s)`
607
+ : 'Même nombre de violations';
608
+ const text = `# Re-check RGAA — ${result.url}\n\n` +
609
+ `| Métrique | Avant | Après | Delta |\n` +
610
+ `|----------|-------|-------|-------|\n` +
611
+ `| Score | ${result.previousScore}% | ${result.score}% | ${deltaSign}${result.score - result.previousScore} |\n` +
612
+ `| Violations | ${result.previousViolations} | ${result.violations} | ${result.violationsDelta > 0 ? '-' : '+'}${Math.abs(result.violationsDelta)} |\n\n` +
613
+ `**${result.message}**\n\n` +
614
+ `${violFixedText}\n\n` +
615
+ `- Audit précédent : ${result.parentAuditId}\n` +
616
+ `- Nouvel audit : ${result.newAuditId}\n\n` +
617
+ `Pour voir le détail des violations restantes, lancez \`rgaa_audit\` avec l'URL ou consultez l'audit dans l'interface.`;
618
+ return { content: [{ type: 'text', text }] };
619
+ }
620
+ catch (error) {
621
+ return { content: [{ type: 'text', text: `Erreur : ${error instanceof Error ? error.message : String(error)}` }], isError: true };
622
+ }
623
+ });
624
+ // ── Outil 8 : rgaa_visual_check ──
625
+ /**
626
+ * Checklist de vérification visuelle structurée.
627
+ * Chaque étape dit à l'agent quoi faire avec Browser MCP et quoi vérifier.
628
+ */
629
+ const VISUAL_CHECKS = [
630
+ {
631
+ id: 'skip-link',
632
+ criteria: '12.7',
633
+ title: 'Lien d\'évitement visible au focus',
634
+ steps: [
635
+ 'browser_navigate vers l\'URL',
636
+ 'browser_press_key("Tab") — 1 fois',
637
+ 'browser_screenshot — capturer l\'état du focus',
638
+ ],
639
+ lookFor: 'Un lien "Aller au contenu" doit apparaître en haut de page au premier Tab. S\'il est invisible ou absent, c\'est une violation.',
640
+ },
641
+ {
642
+ id: 'focus-visible',
643
+ criteria: '10.7',
644
+ title: 'Indicateur de focus visible sur les éléments interactifs',
645
+ steps: [
646
+ 'browser_press_key("Tab") — 5 fois successives',
647
+ 'browser_screenshot à chaque Tab',
648
+ ],
649
+ lookFor: 'Chaque élément focusé (lien, bouton, champ) doit avoir un contour/outline visible. Si le focus est invisible (pas de ring, pas de bordure), c\'est une violation.',
650
+ },
651
+ {
652
+ id: 'contrast-visual',
653
+ criteria: '3.3',
654
+ title: 'Contraste visuel — zones potentiellement problématiques',
655
+ steps: [
656
+ 'browser_screenshot de la page complète',
657
+ 'Examiner le footer, les badges, les textes muted/secondaires',
658
+ ],
659
+ lookFor: 'Texte gris clair sur fond blanc, texte clair sur fond coloré, badges/tags peu contrastés. Les zones footer et navigation sont souvent problématiques.',
660
+ },
661
+ {
662
+ id: 'responsive-320',
663
+ criteria: '10.11',
664
+ title: 'Responsive — pas de scroll horizontal à 320px',
665
+ steps: [
666
+ 'browser_press_key avec Ctrl+Shift+M (toggle device mode) ou resize la fenêtre à 320px de large',
667
+ 'browser_screenshot',
668
+ ],
669
+ lookFor: 'Aucun contenu ne doit déborder horizontalement. Pas de barre de scroll horizontal. Le texte doit rester lisible.',
670
+ },
671
+ {
672
+ id: 'heading-hierarchy',
673
+ criteria: '9.1',
674
+ title: 'Hiérarchie de titres logique',
675
+ steps: [
676
+ 'browser_snapshot — lire l\'arbre d\'accessibilité',
677
+ 'Lister tous les headings (h1-h6) dans l\'ordre',
678
+ ],
679
+ lookFor: 'Un seul h1, puis h2, h3 dans l\'ordre. Pas de saut (h1→h4). Pas de h1 manquant. Vérifier que le h1 décrit le contenu principal.',
680
+ },
681
+ {
682
+ id: 'images-alt',
683
+ criteria: '1.1',
684
+ title: 'Images informatives avec alternative textuelle',
685
+ steps: [
686
+ 'browser_snapshot — chercher toutes les images dans l\'arbre d\'accessibilité',
687
+ ],
688
+ lookFor: 'Chaque image informative doit avoir un alt descriptif. Les images décoratives doivent avoir alt="". Les icônes dans les boutons/liens doivent être aria-hidden avec un texte accessible sur le parent.',
689
+ },
690
+ {
691
+ id: 'form-labels',
692
+ criteria: '11.1',
693
+ title: 'Champs de formulaire avec étiquettes',
694
+ steps: [
695
+ 'browser_snapshot — chercher les champs input/select/textarea',
696
+ ],
697
+ lookFor: 'Chaque champ doit avoir un label associé (via for/id ou aria-label). Les placeholder seuls ne sont PAS des labels valides.',
698
+ },
699
+ {
700
+ id: 'new-window',
701
+ criteria: '13.2',
702
+ title: 'Liens ouvrant une nouvelle fenêtre',
703
+ steps: [
704
+ 'browser_snapshot — chercher les liens avec target="_blank"',
705
+ ],
706
+ lookFor: 'Chaque lien target="_blank" doit indiquer "(nouvelle fenêtre)" en texte, sr-only, ou via une icône avec aria-label.',
707
+ },
708
+ {
709
+ id: 'keyboard-nav',
710
+ criteria: '12.8',
711
+ title: 'Navigation clavier complète',
712
+ steps: [
713
+ 'browser_press_key("Tab") — naviguer dans toute la page',
714
+ 'browser_press_key("Enter") sur les boutons/liens',
715
+ 'browser_press_key("Escape") pour fermer les modales',
716
+ ],
717
+ lookFor: 'Tous les éléments interactifs doivent être atteignables au clavier. Pas de piège (impossible de sortir d\'un élément). L\'ordre de tabulation doit suivre l\'ordre visuel.',
718
+ },
719
+ {
720
+ id: 'landmarks',
721
+ criteria: '12.9',
722
+ title: 'Zones principales identifiées par des landmarks',
723
+ steps: [
724
+ 'browser_snapshot — vérifier la structure de la page',
725
+ ],
726
+ lookFor: 'La page doit avoir : header (banner), nav (navigation), main, footer (contentinfo). Chaque zone doit être un landmark sémantique.',
727
+ },
728
+ ];
729
+ server.tool('rgaa_visual_check', 'Génère une checklist de vérification visuelle pour l\'agent AI. Chaque étape indique quoi faire avec Browser MCP (navigate, screenshot, snapshot, Tab) et quoi vérifier visuellement. Couvre les critères que axe-core ne peut pas détecter automatiquement.', {
730
+ url: z.string().url().describe('URL de la page à vérifier visuellement'),
731
+ checks: z.array(z.string()).optional().describe('Liste d\'IDs de vérifications à effectuer (défaut: toutes). IDs: skip-link, focus-visible, contrast-visual, responsive-320, heading-hierarchy, images-alt, form-labels, new-window, keyboard-nav, landmarks'),
732
+ }, async ({ url, checks }) => {
733
+ const selectedChecks = checks && checks.length > 0
734
+ ? VISUAL_CHECKS.filter(c => checks.includes(c.id))
735
+ : VISUAL_CHECKS;
736
+ let text = `# Vérification visuelle RGAA — ${url}\n\n`;
737
+ text += `**${selectedChecks.length} vérifications** à effectuer avec Browser MCP.\n`;
738
+ text += `Chaque étape indique les actions Browser MCP à exécuter et ce qu'il faut vérifier.\n\n`;
739
+ text += `---\n\n`;
740
+ for (const [i, check] of selectedChecks.entries()) {
741
+ text += `## ${i + 1}. ${check.title} (RGAA ${check.criteria})\n\n`;
742
+ text += `**Actions** :\n`;
743
+ for (const step of check.steps) {
744
+ text += `1. \`${step}\`\n`;
745
+ }
746
+ text += `\n**Ce qu'il faut vérifier** : ${check.lookFor}\n\n`;
747
+ text += `---\n\n`;
748
+ }
749
+ text += `## Instructions pour l'agent\n\n`;
750
+ text += `1. Commencez par \`browser_navigate("${url}")\`\n`;
751
+ text += `2. Exécutez chaque vérification dans l'ordre\n`;
752
+ text += `3. Pour chaque problème trouvé, notez le critère RGAA et la description\n`;
753
+ text += `4. Après toutes les vérifications, résumez les problèmes trouvés\n`;
754
+ text += `5. Utilisez \`rgaa_fix\` pour obtenir le code correctif de chaque problème\n`;
755
+ return { content: [{ type: 'text', text }] };
756
+ });
757
+ // ── Outil 9 : rgaa_report ──
556
758
  server.tool('rgaa_report', 'Génère un rapport d\'audit RGAA structuré en JSON pour intégration CI/CD ou export.', {
557
759
  url: z.string().url().describe('URL du site web'),
558
760
  threshold: z.number().min(0).max(100).default(0).describe('Score minimum pour pass/fail (0 = pas de seuil)'),
@@ -569,7 +771,7 @@ server.tool('rgaa_report', 'Génère un rapport d\'audit RGAA structuré en JSON
569
771
  async function main() {
570
772
  if (!API_KEY) {
571
773
  console.error('⚠️ RGAA_API_KEY non configurée. Les outils d\'audit ne fonctionneront pas.');
572
- console.error(' Générez une clé sur https://app.rgaaudit.fr > Paramètres > API');
774
+ console.error(' Générez une clé sur https://rgaaudit.fr > Paramètres > API');
573
775
  }
574
776
  const transport = new StdioServerTransport();
575
777
  await server.connect(transport);
package/package.json CHANGED
@@ -1,52 +1,50 @@
1
- {
2
- "name": "@rgaaudit/mcp-server",
3
- "version": "0.3.0",
4
- "description": "MCP Server RGAA 4.1 — Audit d'accessibilité pour Claude Code, Cursor, Copilot",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "bin": {
8
- "rgaaudit-mcp": "dist/index.js"
9
- },
10
- "files": [
11
- "dist",
12
- "README.md"
13
- ],
14
- "scripts": {
15
- "build": "tsc",
16
- "dev": "tsx src/index.ts",
17
- "start": "node dist/index.js",
18
- "prepublishOnly": "npm run build"
19
- },
20
- "dependencies": {
21
- "@axe-core/puppeteer": "^4.11.1",
22
- "@modelcontextprotocol/sdk": "^1.12.1",
23
- "puppeteer-core": "^24.40.0",
24
- "zod": "^3.25.3"
25
- },
26
- "devDependencies": {
27
- "tsx": "^4.19.4",
28
- "typescript": "^5.8.3"
29
- },
30
- "engines": {
31
- "node": ">=18.0.0"
32
- },
33
- "keywords": [
34
- "mcp",
35
- "rgaa",
36
- "accessibility",
37
- "a11y",
38
- "wcag",
39
- "audit",
40
- "france",
41
- "rgaa4"
42
- ],
43
- "license": "MIT",
44
- "repository": {
45
- "type": "git",
46
- "url": "https://github.com/rgaaudit/mcp-server"
47
- },
48
- "homepage": "https://rgaaudit.fr/docs/mcp",
49
- "publishConfig": {
50
- "access": "public"
51
- }
52
- }
1
+ {
2
+ "name": "@rgaaudit/mcp-server",
3
+ "version": "0.4.0",
4
+ "description": "MCP Server RGAA 4.1 — Audit d'accessibilité pour Claude Code, Cursor, Copilot",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "rgaaudit-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsx src/index.ts",
17
+ "start": "node dist/index.js",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.12.1",
22
+ "zod": "^3.25.3"
23
+ },
24
+ "devDependencies": {
25
+ "tsx": "^4.19.4",
26
+ "typescript": "^5.8.3"
27
+ },
28
+ "engines": {
29
+ "node": ">=18.0.0"
30
+ },
31
+ "keywords": [
32
+ "mcp",
33
+ "rgaa",
34
+ "accessibility",
35
+ "a11y",
36
+ "wcag",
37
+ "audit",
38
+ "france",
39
+ "rgaa4"
40
+ ],
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/Foxbane-c/RGAAudit"
45
+ },
46
+ "homepage": "https://rgaaudit.fr/docs/mcp",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }
@@ -1,66 +0,0 @@
1
- /**
2
- * Scanner local axe-core — Mode localhost
3
- *
4
- * Utilise puppeteer-core (Chrome existant) + @axe-core/puppeteer
5
- * pour auditer des URLs localhost sans passer par l'API distante.
6
- *
7
- * Le mapping RGAA est ensuite récupéré via POST /api/ci/map.
8
- */
9
- /** Résultat brut d'un scan local axe-core */
10
- export interface LocalScanResult {
11
- url: string;
12
- violations: LocalViolation[];
13
- passes: number;
14
- incomplete: number;
15
- timestamp: string;
16
- }
17
- export interface LocalViolation {
18
- ruleId: string;
19
- impact: string;
20
- description: string;
21
- help: string;
22
- helpUrl: string;
23
- nodesCount: number;
24
- nodes: Array<{
25
- html: string;
26
- target: string;
27
- }>;
28
- }
29
- /** Mapping RGAA retourné par /api/ci/map */
30
- export interface RgaaMapping {
31
- criteria: string;
32
- title: string;
33
- theme: string | null;
34
- impact: string | null;
35
- help: string | null;
36
- level: string | null;
37
- example: string | null;
38
- }
39
- export interface MapResponse {
40
- mappings: Record<string, RgaaMapping>;
41
- coverage: {
42
- mappedCriteria: number;
43
- totalCriteria: number;
44
- percentage: number;
45
- };
46
- }
47
- /**
48
- * Détecte si une URL est locale (localhost, 127.0.0.1, 0.0.0.0)
49
- */
50
- export declare function isLocalUrl(url: string): boolean;
51
- /**
52
- * Lance un scan axe-core local sur une URL
53
- *
54
- * @param url - URL locale à scanner (localhost, 127.0.0.1)
55
- * @returns Violations axe-core brutes
56
- */
57
- export declare function scanLocal(url: string): Promise<LocalScanResult>;
58
- /**
59
- * Appelle POST /api/ci/map pour enrichir les violations avec le mapping RGAA
60
- *
61
- * @param ruleIds - Liste de ruleIds axe-core
62
- * @param apiUrl - URL de l'API RGAAudit
63
- * @param apiKey - Clé API
64
- * @returns Mappings RGAA ou null si l'API est inaccessible
65
- */
66
- export declare function fetchRgaaMappings(ruleIds: string[], apiUrl: string, apiKey: string): Promise<MapResponse | null>;
@@ -1,156 +0,0 @@
1
- /**
2
- * Scanner local axe-core — Mode localhost
3
- *
4
- * Utilise puppeteer-core (Chrome existant) + @axe-core/puppeteer
5
- * pour auditer des URLs localhost sans passer par l'API distante.
6
- *
7
- * Le mapping RGAA est ensuite récupéré via POST /api/ci/map.
8
- */
9
- /**
10
- * Détecte si une URL est locale (localhost, 127.0.0.1, 0.0.0.0)
11
- */
12
- export function isLocalUrl(url) {
13
- try {
14
- const parsed = new URL(url);
15
- return (parsed.hostname === 'localhost' ||
16
- parsed.hostname === '127.0.0.1' ||
17
- parsed.hostname === '0.0.0.0' ||
18
- parsed.hostname === '::1');
19
- }
20
- catch {
21
- return false;
22
- }
23
- }
24
- /**
25
- * Trouve le chemin de Chrome/Chromium installé sur le système
26
- */
27
- async function findChromePath() {
28
- const { existsSync } = await import('fs');
29
- const { join } = await import('path');
30
- // Variable d'env explicite (priorité absolue, pas d'exécution shell)
31
- if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) {
32
- return process.env.CHROME_PATH;
33
- }
34
- // Chemins connus par OS (pas d'exécution shell, pas d'injection possible)
35
- const candidates = [];
36
- // Windows — chemins standards via variables d'env système
37
- const winDirs = [
38
- process.env['PROGRAMFILES'],
39
- process.env['PROGRAMFILES(X86)'],
40
- process.env['LOCALAPPDATA'],
41
- ].filter(Boolean);
42
- for (const dir of winDirs) {
43
- candidates.push(join(dir, 'Google', 'Chrome', 'Application', 'chrome.exe'));
44
- candidates.push(join(dir, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
45
- }
46
- // macOS
47
- candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge');
48
- // Linux
49
- candidates.push('/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium');
50
- for (const p of candidates) {
51
- if (existsSync(p))
52
- return p;
53
- }
54
- return null;
55
- }
56
- /**
57
- * Lance un scan axe-core local sur une URL
58
- *
59
- * @param url - URL locale à scanner (localhost, 127.0.0.1)
60
- * @returns Violations axe-core brutes
61
- */
62
- export async function scanLocal(url) {
63
- // Validation URL : seuls http/https autorisés, longueur limitée
64
- const parsed = new URL(url);
65
- if (!['http:', 'https:'].includes(parsed.protocol)) {
66
- throw new Error('Seuls les protocoles http et https sont autorisés');
67
- }
68
- if (url.length > 2048) {
69
- throw new Error('URL trop longue (max 2048 caractères)');
70
- }
71
- const chromePath = await findChromePath();
72
- if (!chromePath) {
73
- throw new Error('Chrome/Chromium non trouvé sur ce système.\n\n' +
74
- 'Le scan localhost nécessite un navigateur Chromium installé.\n' +
75
- 'Installez Google Chrome ou Chromium, puis réessayez.\n\n' +
76
- 'Chemins recherchés :\n' +
77
- '- Windows : Program Files\\Google\\Chrome\\Application\\chrome.exe\n' +
78
- '- macOS : /Applications/Google Chrome.app\n' +
79
- '- Linux : /usr/bin/google-chrome ou /usr/bin/chromium');
80
- }
81
- // Import dynamique pour ne pas crasher si les deps ne sont pas installées
82
- const puppeteer = await import('puppeteer-core');
83
- const { AxePuppeteer } = await import('@axe-core/puppeteer');
84
- let browser = null;
85
- try {
86
- browser = await puppeteer.default.launch({
87
- executablePath: chromePath,
88
- headless: true,
89
- args: [
90
- '--no-sandbox',
91
- '--disable-setuid-sandbox',
92
- '--disable-dev-shm-usage',
93
- '--disable-gpu',
94
- '--disable-extensions',
95
- ],
96
- });
97
- const page = await browser.newPage();
98
- await page.setViewport({ width: 1280, height: 720 });
99
- // Naviguer avec timeout
100
- await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
101
- // Lancer axe-core
102
- const results = await new AxePuppeteer(page)
103
- .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'])
104
- .analyze();
105
- const violations = results.violations.map(v => ({
106
- ruleId: v.id,
107
- impact: v.impact || 'moderate',
108
- description: v.description,
109
- help: v.help,
110
- helpUrl: v.helpUrl,
111
- nodesCount: v.nodes.length,
112
- nodes: v.nodes.slice(0, 5).map(n => ({
113
- html: (n.html || '').slice(0, 200),
114
- target: Array.isArray(n.target) ? n.target.join(' > ') : String(n.target),
115
- })),
116
- }));
117
- return {
118
- url,
119
- violations,
120
- passes: results.passes.length,
121
- incomplete: results.incomplete.length,
122
- timestamp: new Date().toISOString(),
123
- };
124
- }
125
- finally {
126
- if (browser)
127
- await browser.close().catch(() => { });
128
- }
129
- }
130
- /**
131
- * Appelle POST /api/ci/map pour enrichir les violations avec le mapping RGAA
132
- *
133
- * @param ruleIds - Liste de ruleIds axe-core
134
- * @param apiUrl - URL de l'API RGAAudit
135
- * @param apiKey - Clé API
136
- * @returns Mappings RGAA ou null si l'API est inaccessible
137
- */
138
- export async function fetchRgaaMappings(ruleIds, apiUrl, apiKey) {
139
- try {
140
- const res = await fetch(`${apiUrl}/api/ci/map`, {
141
- method: 'POST',
142
- headers: {
143
- 'Content-Type': 'application/json',
144
- 'X-API-Key': apiKey,
145
- },
146
- body: JSON.stringify({ ruleIds }),
147
- });
148
- if (!res.ok)
149
- return null;
150
- return res.json();
151
- }
152
- catch {
153
- // API inaccessible (offline, pas de connexion) → retour null
154
- return null;
155
- }
156
- }