1import { Actor, log } from 'apify';
2import { chromium, type Browser } from 'playwright';
3import AxeBuilder from '@axe-core/playwright';
4import type { AxeResults, ImpactValue } from 'axe-core';
5
6
7
8interface ActorInput {
9 startUrls: { url: string }[];
10 wcagLevels?: string[];
11 maxPages?: number;
12 timeout?: number;
13 screenshot?: boolean;
14 waitForSelector?: string;
15
16 webhookUrl?: string;
17
18 runLighthouse?: boolean;
19}
20
21interface ViolationNode {
22 target: string[];
23 html: string;
24 failureSummary: string;
25}
26
27interface Violation {
28 ruleId: string;
29 description: string;
30 impact: 'critical' | 'serious' | 'moderate' | 'minor';
31 tags: string[];
32 help: string;
33 helpUrl: string;
34 nodes: ViolationNode[];
35}
36
37interface PageSummary {
38 totalViolations: number;
39 critical: number;
40 serious: number;
41 moderate: number;
42 minor: number;
43 passes: number;
44 incomplete: number;
45 complianceScore: number;
46 grade: string;
47 lawsuitRisk: string;
48}
49
50interface PageResult {
51 url: string;
52 pageTitle: string;
53 scanDate: string;
54 summary: PageSummary;
55 violations: Violation[];
56 passes: { ruleId: string; description: string }[];
57 incomplete: { ruleId: string; description: string; impact: string }[];
58}
59
60interface ActorOutput {
61 totalPages: number;
62 totalViolations: number;
63 overallScore: number;
64 overallGrade: string;
65 overallRisk: string;
66 results: PageResult[];
67}
68
69
70
71function calculateScore(violations: Violation[]): number {
72 const weights: Record<string, number> = {
73 critical: 25,
74 serious: 10,
75 moderate: 5,
76 minor: 1,
77 };
78 let penalty = 0;
79 for (const v of violations) {
80 penalty += weights[v.impact] || 1;
81 }
82 return Math.max(0, Math.min(100, 100 - penalty));
83}
84
85function calculateGrade(score: number): string {
86 if (score >= 95) return 'A';
87 if (score >= 85) return 'B';
88 if (score >= 70) return 'C';
89 if (score >= 50) return 'D';
90 return 'F';
91}
92
93function calculateRisk(violations: Violation[]): string {
94 const critical = violations.filter(v => v.impact === 'critical').length;
95 const serious = violations.filter(v => v.impact === 'serious').length;
96 const total = violations.length;
97
98 if (critical >= 5 || total >= 20) return 'Critical';
99 if (critical >= 2 || serious >= 5 || total >= 10) return 'High';
100 if (serious >= 2 || total >= 5) return 'Medium';
101 return 'Low';
102}
103
104function buildAxeTags(wcagLevels: string[]): string[] {
105 const tags: string[] = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
106 if (wcagLevels.includes('AAA')) {
107 tags.push('wcag2aaa', 'wcag21aaa');
108 }
109 return [...Array.from(new Set(tags))];
110}
111
112function mapAxeResult(result: AxeResults, url: string, pageTitle: string): PageResult {
113 const violations: Violation[] = result.violations.map(v => ({
114 ruleId: v.id,
115 description: v.description,
116 impact: (v.impact || 'minor') as 'critical' | 'serious' | 'moderate' | 'minor',
117 tags: v.tags,
118 help: v.help,
119 helpUrl: v.helpUrl,
120 nodes: v.nodes.map(n => ({
121 target: n.target as string[],
122 html: n.html,
123 failureSummary: n.failureSummary || '',
124 })),
125 }));
126
127 const summary: PageSummary = {
128 totalViolations: violations.length,
129 critical: violations.filter(v => v.impact === 'critical').length,
130 serious: violations.filter(v => v.impact === 'serious').length,
131 moderate: violations.filter(v => v.impact === 'moderate').length,
132 minor: violations.filter(v => v.impact === 'minor').length,
133 passes: result.passes.length,
134 incomplete: result.incomplete.length,
135 complianceScore: 0,
136 grade: '',
137 lawsuitRisk: '',
138 };
139 summary.complianceScore = calculateScore(violations);
140 summary.grade = calculateGrade(summary.complianceScore);
141 summary.lawsuitRisk = calculateRisk(violations);
142
143 const passes = result.passes.map(p => ({
144 ruleId: p.id,
145 description: p.description,
146 }));
147
148 const incomplete = result.incomplete.map(i => ({
149 ruleId: i.id,
150 description: i.description,
151 impact: i.impact || 'unknown',
152 }));
153
154 return {
155 url,
156 pageTitle,
157 scanDate: new Date().toISOString(),
158 summary,
159 violations,
160 passes,
161 incomplete,
162 };
163}
164
165async function scanPage(
166 browser: Browser,
167 url: string,
168 options: {
169 wcagLevels: string[];
170 timeout: number;
171 screenshot: boolean;
172 waitForSelector?: string;
173 }
174): Promise<PageResult> {
175 const context = await browser.newContext({
176 viewport: { width: 1280, height: 720 },
177 });
178 const page = await context.newPage();
179
180 try {
181 await page.goto(url, {
182 timeout: options.timeout,
183 waitUntil: 'domcontentloaded',
184 });
185
186 if (options.waitForSelector) {
187 await page.waitForSelector(options.waitForSelector, {
188 timeout: options.timeout,
189 });
190 } else {
191 await page.waitForSelector('body', { timeout: 5000 });
192 }
193
194
195 await page.waitForTimeout(500);
196
197
198 const axeTags = buildAxeTags(options.wcagLevels);
199
200 const axeBuilder = new AxeBuilder({ page: page as any })
201 .withTags(axeTags)
202 .options({
203 runOnly: {
204 type: 'tag',
205 values: axeTags,
206 },
207 resultTypes: ['violations', 'passes', 'incomplete'],
208 });
209
210 const axeResult: AxeResults = await axeBuilder.analyze();
211
212 const pageTitle = await page.title();
213
214
215 if (options.screenshot) {
216 const screenshotBuffer = await page.screenshot({ fullPage: false });
217 const key = `screenshot_${url.replace(/[^a-z0-9]/gi, '_')}.png`;
218 const kvStore = await Actor.openKeyValueStore();
219 await kvStore.setValue(key, screenshotBuffer, { contentType: 'image/png' });
220 }
221
222 return mapAxeResult(axeResult, url, pageTitle || url);
223 } finally {
224 await context.close();
225 }
226}
227
228
229
230async function main() {
231 await Actor.init();
232
233 const input = (await Actor.getInput()) as ActorInput;
234
235 if (!input?.startUrls || input.startUrls.length === 0) {
236 log.error('No startUrls provided in input');
237 await Actor.exit('No startUrls provided', { exitCode: 1 });
238 return;
239 }
240
241 const wcagLevels = input.wcagLevels || ['AA'];
242 const maxPages = input.maxPages || 1;
243 const timeout = input.timeout || 30000;
244 const screenshot = input.screenshot || false;
245 const waitForSelector = input.waitForSelector;
246 const webhookUrl = input.webhookUrl;
247 const runLighthouse = input.runLighthouse || false;
248
249 log.info(`Starting ADA/WCAG scan for ${input.startUrls.length} URL(s)`);
250 log.info(`WCAG Levels: ${wcagLevels.join(', ')}`);
251 log.info(`Max pages per URL: ${maxPages}`);
252
253 const browser = await chromium.launch({
254 headless: true,
255 args: ['--disable-gpu', '--no-sandbox', '--disable-setuid-sandbox'],
256 });
257
258 const results: PageResult[] = [];
259
260 try {
261 for (const startUrl of input.startUrls) {
262 const url = startUrl.url;
263 log.info(`Scanning: ${url}`);
264
265
266 const pageResult = await scanPage(browser, url, {
267 wcagLevels,
268 timeout,
269 screenshot,
270 waitForSelector,
271 });
272
273
274 await Actor.charge({ eventName: 'SCAN', count: 1 });
275
276
277 await Actor.pushData(pageResult);
278 results.push(pageResult);
279
280 log.info(
281 ` Found ${pageResult.summary.totalViolations} violations ` +
282 `(C:${pageResult.summary.critical} S:${pageResult.summary.serious} ` +
283 `M:${pageResult.summary.moderate} m:${pageResult.summary.minor}) ` +
284 `Score: ${pageResult.summary.complianceScore} Grade: ${pageResult.summary.grade} ` +
285 `Risk: ${pageResult.summary.lawsuitRisk}`
286 );
287
288
289 if (maxPages > 1) {
290 try {
291
292 const context = await browser.newContext();
293 const page = await context.newPage();
294 await page.goto(url, { timeout, waitUntil: 'domcontentloaded' });
295
296 const baseUrl = new URL(url);
297 const origin = baseUrl.origin;
298
299 const links = await page.$$eval('a[href]', (anchors, baseOrigin) => {
300 return anchors
301 .map(a => (a as HTMLAnchorElement).href)
302 .filter(href => href && href.startsWith(baseOrigin))
303 .map(href => href.split('#')[0].split('?')[0]);
304 }, origin);
305
306 await context.close();
307
308 const seen = new Set([url]);
309 const uniqueLinks: string[] = [];
310 for (const link of links) {
311 if (!seen.has(link)) {
312 seen.add(link);
313 uniqueLinks.push(link);
314 }
315 }
316
317 for (const subUrl of uniqueLinks.slice(0, maxPages - 1)) {
318 log.info(`Scanning sub-page: ${subUrl}`);
319 try {
320 const subResult = await scanPage(browser, subUrl, {
321 wcagLevels,
322 timeout,
323 screenshot,
324 waitForSelector,
325 });
326 await Actor.charge({ eventName: 'SCAN', count: 1 });
327 await Actor.pushData(subResult);
328 results.push(subResult);
329
330 log.info(
331 ` Found ${subResult.summary.totalViolations} violations ` +
332 `Score: ${subResult.summary.complianceScore} Grade: ${subResult.summary.grade}`
333 );
334 } catch (error) {
335 const err = error as Error;
336 log.warning(` Failed to scan ${subUrl}: ${err.message}`);
337 }
338 }
339 } catch (error) {
340 const err = error as Error;
341 log.warning(`Link discovery failed for ${url}: ${err.message}`);
342 }
343 }
344 }
345 } finally {
346 await browser.close();
347 }
348
349
350 const totalPages = results.length;
351 const totalViolations = results.reduce((sum, r) => sum + r.summary.totalViolations, 0);
352 const allScores = results.map(r => r.summary.complianceScore);
353 const overallScore = allScores.length > 0
354 ? Math.round(allScores.reduce((a, b) => a + b, 0) / allScores.length)
355 : 0;
356
357 const output: ActorOutput = {
358 totalPages,
359 totalViolations,
360 overallScore,
361 overallGrade: calculateGrade(overallScore),
362 overallRisk: calculateRisk(results.flatMap(r => r.violations)),
363 results,
364 };
365
366 log.info(
367 `Scan complete: ${totalPages} pages, ${totalViolations} violations, ` +
368 `Score: ${overallScore} Grade: ${output.overallGrade} Risk: ${output.overallRisk}`
369 );
370
371
372 if (runLighthouse) {
373 log.info('Running Lighthouse performance audit...');
374 try {
375 const lighthouseResults: any[] = [];
376 for (const startUrl of input.startUrls) {
377 const url = startUrl.url;
378 log.info(` Lighthouse: ${url}`);
379
380 const psiResp = await fetch(
381 `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=mobile&category=performance`
382 );
383 if (psiResp.ok) {
384 const psiData = await psiResp.json() as any;
385 const lh = psiData?.lighthouseResult;
386 if (lh) {
387 const perf = lh.categories?.performance?.score || 0;
388 const fcp = lh.audits?.['first-contentful-paint']?.displayValue || 'N/A';
389 const lcp = lh.audits?.['largest-contentful-paint']?.displayValue || 'N/A';
390 const cls = lh.audits?.['cumulative-layout-shift']?.displayValue || 'N/A';
391 const tbt = lh.audits?.['total-blocking-time']?.displayValue || 'N/A';
392
393 lighthouseResults.push({
394 url,
395 performanceScore: Math.round(perf * 100),
396 firstContentfulPaint: fcp,
397 largestContentfulPaint: lcp,
398 cumulativeLayoutShift: cls,
399 totalBlockingTime: tbt,
400 });
401
402 log.info(` Performance: ${Math.round(perf * 100)}/100 | LCP: ${lcp} | CLS: ${cls}`);
403 }
404 }
405 }
406 if (lighthouseResults.length > 0) {
407 (output as any).lighthouseResults = lighthouseResults;
408 }
409 } catch (error) {
410 log.warning(`Lighthouse audit failed: ${(error as Error).message}`);
411 }
412 }
413
414
415 if (webhookUrl && totalViolations > 0) {
416 log.info(`Sending webhook alert to ${webhookUrl}...`);
417 try {
418 const webhookPayload = {
419 event: 'ada_scan_complete',
420 timestamp: new Date().toISOString(),
421 totalPages,
422 totalViolations,
423 overallScore,
424 overallGrade: output.overallGrade,
425 overallRisk: output.overallRisk,
426 criticalCount: results.reduce((s, r) => s + r.summary.critical, 0),
427 seriousCount: results.reduce((s, r) => s + r.summary.serious, 0),
428 urls: input.startUrls.map(s => s.url),
429 summary: `${totalViolations} violations found across ${totalPages} pages. Score: ${overallScore}/100 (Grade ${output.overallGrade}). Risk: ${output.overallRisk}.`,
430 };
431
432 const webhookResp = await fetch(webhookUrl, {
433 method: 'POST',
434 headers: { 'Content-Type': 'application/json' },
435 body: JSON.stringify(webhookPayload),
436 });
437
438 if (webhookResp.ok) {
439 log.info(' Webhook sent successfully');
440 } else {
441 log.warning(` Webhook returned ${webhookResp.status}`);
442 }
443 } catch (error) {
444 log.warning(` Webhook failed: ${(error as Error).message}`);
445 }
446 }
447
448 const kvStore = await Actor.openKeyValueStore();
449 await kvStore.setValue('OUTPUT', output);
450
451 await Actor.exit();
452}
453
454main().catch(async (error) => {
455 console.error('Fatal error:', error);
456 await Actor.exit('Fatal error', { exitCode: 1 });
457});