1import { Actor, log } from 'apify';
2import { chromium, type Browser } from 'playwright';
3import AxeBuilder from '@axe-core/playwright';
4import type { AxeResults } from 'axe-core';
5
6
7
8interface ActorInput {
9 startUrls?: { url: string }[];
10 scanResult?: ScanPageResult[];
11 competitorUrls?: { url: string }[];
12 reportFormat?: 'json' | 'markdown' | 'both';
13 llmModel?: string;
14
15 prioritizeByImpact?: boolean;
16}
17
18interface ViolationNode {
19 target: string[];
20 html: string;
21 failureSummary: string;
22}
23
24interface Violation {
25 ruleId: string;
26 description: string;
27 impact: 'critical' | 'serious' | 'moderate' | 'minor';
28 tags: string[];
29 help: string;
30 helpUrl: string;
31 nodes: ViolationNode[];
32}
33
34interface ScanPageResult {
35 url: string;
36 pageTitle: string;
37 scanDate: string;
38 summary: {
39 totalViolations: number;
40 critical: number;
41 serious: number;
42 moderate: number;
43 minor: number;
44 passes: number;
45 incomplete: number;
46 complianceScore: number;
47 grade: string;
48 lawsuitRisk: string;
49 };
50 violations: Violation[];
51 passes: { ruleId: string; description: string }[];
52 incomplete: { ruleId: string; description: string; impact: string }[];
53}
54
55interface RemediationItem {
56 ruleId: string;
57 impact: string;
58 plainEnglish: string;
59 technicalDescription: string;
60 affectedElements: number;
61 codeSnippet: string;
62 costLow: number;
63 costHigh: number;
64 estimatedHours: number;
65 helpUrl: string;
66 examples: { target: string[]; html: string; failureSummary: string }[];
67}
68
69interface PageRemediationReport {
70 url: string;
71 pageTitle: string;
72 scanDate: string;
73 remediationSummary: {
74 totalCostLow: number;
75 totalCostHigh: number;
76 totalHours: number;
77 criticalCount: number;
78 seriousCount: number;
79 moderateCount: number;
80 minorCount: number;
81 recommendedFixBy: string;
82 grade: string;
83 lawsuitRisk: string;
84 complianceScore: number;
85 };
86 remediationItems: RemediationItem[];
87 itemsByPriority: {
88 critical: RemediationItem[];
89 serious: RemediationItem[];
90 moderate: RemediationItem[];
91 minor: RemediationItem[];
92 };
93}
94
95interface CompetitorResult {
96 url: string;
97 pageTitle: string;
98 score: number;
99 grade: string;
100 risk: string;
101 totalViolations: number;
102 critical: number;
103 serious: number;
104}
105
106interface ActorOutput {
107 reportDate: string;
108 totalPages: number;
109 totalCostLow: number;
110 totalCostHigh: number;
111 totalHours: number;
112 overallGrade: string;
113 overallRisk: string;
114 recommendedFixBy: string;
115 reports: PageRemediationReport[];
116 competitors?: CompetitorResult[];
117 markdownReport?: string;
118}
119
120
121
122const COST_TABLE: Record<string, { low: number; high: number; hours: number }> = {
123 'color-contrast': { low: 15, high: 30, hours: 0.5 },
124 'html-has-lang': { low: 5, high: 10, hours: 0.1 },
125 'image-alt': { low: 8, high: 15, hours: 0.2 },
126 'label': { low: 20, high: 40, hours: 0.5 },
127 'link-name': { low: 10, high: 25, hours: 0.3 },
128 'button-name': { low: 10, high: 25, hours: 0.3 },
129 'empty-heading': { low: 5, high: 15, hours: 0.1 },
130 'duplicate-id': { low: 5, high: 15, hours: 0.1 },
131 'target-size': { low: 10, high: 20, hours: 0.2 },
132 'bypass': { low: 50, high: 100, hours: 1.5 },
133 'heading-order': { low: 15, high: 30, hours: 0.5 },
134 'landmark-main': { low: 30, high: 60, hours: 1 },
135 'landmark-no-duplicate-main': { low: 20, high: 40, hours: 0.5 },
136 'region': { low: 25, high: 50, hours: 0.8 },
137 'tabindex': { low: 15, high: 35, hours: 0.5 },
138 'focus-order': { low: 40, high: 80, hours: 1.5 },
139 'keyboard': { low: 50, high: 120, hours: 2 },
140 'page-has-heading-one': { low: 20, high: 40, hours: 0.5 },
141 'meta-viewport': { low: 5, high: 10, hours: 0.1 },
142 'frame-title': { low: 10, high: 20, hours: 0.2 },
143 'input-button-name': { low: 10, high: 25, hours: 0.3 },
144 'select-name': { low: 15, high: 30, hours: 0.4 },
145 'form-field-multiple-labels': { low: 15, high: 30, hours: 0.4 },
146 'list': { low: 10, high: 25, hours: 0.3 },
147 'listitem': { low: 10, high: 25, hours: 0.3 },
148 'definition-list': { low: 10, high: 25, hours: 0.3 },
149 'dlitem': { low: 10, high: 25, hours: 0.3 },
150 'aria-input-field-name': { low: 15, high: 30, hours: 0.4 },
151 'aria-required-children': { low: 20, high: 40, hours: 0.6 },
152 'aria-required-parent': { low: 20, high: 40, hours: 0.6 },
153 'aria-roles': { low: 20, high: 50, hours: 0.8 },
154 'aria-valid-attr': { low: 15, high: 30, hours: 0.4 },
155 'aria-valid-attr-value': { low: 15, high: 30, hours: 0.4 },
156 'aria-allowed-attr': { low: 15, high: 30, hours: 0.4 },
157 'aria-hidden-focus': { low: 20, high: 40, hours: 0.5 },
158 'aria-describedby': { low: 15, high: 30, hours: 0.4 },
159 'autocomplete-valid': { low: 5, high: 15, hours: 0.1 },
160 'avoid-inline-spacing': { low: 5, high: 10, hours: 0.1 },
161 'css-orientation-lock': { low: 30, high: 60, hours: 1 },
162 'focus-visible': { low: 25, high: 50, hours: 0.8 },
163 'frame-tested': { low: 10, high: 20, hours: 0.2 },
164 'hidden-content': { low: 10, high: 20, hours: 0.2 },
165 'identical-links-same-purpose': { low: 15, high: 30, hours: 0.4 },
166 'link-in-text-block': { low: 15, high: 30, hours: 0.4 },
167 'no-autoplay-audio': { low: 20, high: 40, hours: 0.5 },
168 'password-inputs-can-be-pasted': { low: 10, high: 20, hours: 0.2 },
169 'presentation-role-conflict': { low: 10, high: 25, hours: 0.3 },
170 'role-img-alt': { low: 8, high: 15, hours: 0.2 },
171 'scope-attr-valid': { low: 5, high: 15, hours: 0.1 },
172 'scrollable-region-focusable': { low: 20, high: 40, hours: 0.5 },
173 'server-side-image-map': { low: 30, high: 60, hours: 1 },
174 'svg-img-alt': { low: 8, high: 15, hours: 0.2 },
175 'td-headers-attr': { low: 15, high: 30, hours: 0.4 },
176 'th-has-data-cells': { low: 15, high: 30, hours: 0.4 },
177 'valid-lang': { low: 5, high: 10, hours: 0.1 },
178 'video-caption': { low: 30, high: 60, hours: 1 },
179};
180
181const PLAIN_ENGLISH: Record<string, string> = {
182 'color-contrast': 'Text is too hard to read against its background color',
183 'html-has-lang': 'Page is missing language declaration (needed for screen readers)',
184 'image-alt': 'Images are missing text descriptions for screen reader users',
185 'label': 'Form fields are missing descriptive labels',
186 'link-name': 'Links have no visible text (screen readers cannot describe them)',
187 'button-name': 'Buttons have no accessible text (screen readers cannot identify them)',
188 'empty-heading': 'Empty headings found (confusing for screen reader navigation)',
189 'duplicate-id': 'Duplicate ID attributes found (causes issues with assistive technology)',
190 'target-size': 'Clickable elements are too small for touch screen users',
191 'bypass': 'No skip-to-content link (keyboard users must tab through entire menu)',
192 'heading-order': 'Headings are not in logical order (breaks screen reader navigation)',
193 'landmark-main': 'Page is missing main content landmark',
194 'region': 'Page content is not organized into regions for screen reader navigation',
195 'tabindex': 'Tab order contains elements that break keyboard navigation',
196 'focus-order': 'Keyboard focus order does not match visual order',
197 'keyboard': 'Interactive elements cannot be accessed via keyboard',
198 'page-has-heading-one': 'Page is missing a primary heading',
199 'meta-viewport': 'Viewport meta tag prevents user zoom (blocks low-vision users)',
200 'frame-title': 'Inline frames (iframes) are missing titles',
201 'list': 'List markup is incorrect',
202 'aria-roles': 'ARIA roles are incorrectly assigned',
203 'focus-visible': 'Keyboard focus indicator is not visible',
204 'link-in-text-block': 'Links cannot be distinguished from surrounding text without color',
205};
206
207function getCostEstimate(ruleId: string, elementCount: number): { low: number; high: number; hours: number } {
208 const base = COST_TABLE[ruleId] || { low: 15, high: 30, hours: 0.4 };
209 return {
210 low: base.low * elementCount,
211 high: base.high * elementCount,
212 hours: base.hours * elementCount,
213 };
214}
215
216function getPlainEnglish(ruleId: string, description: string): string {
217 return PLAIN_ENGLISH[ruleId] || description;
218}
219
220function getRecommendedFixBy(scanDate: string, impact: string): string {
221 const date = new Date(scanDate);
222 const days: Record<string, number> = {
223 critical: 14,
224 serious: 30,
225 moderate: 90,
226 minor: 180,
227 };
228 const addDays = days[impact] || 90;
229 date.setDate(date.getDate() + addDays);
230 return date.toISOString().split('T')[0];
231}
232
233function calculateGrade(score: number): string {
234 if (score >= 95) return 'A';
235 if (score >= 85) return 'B';
236 if (score >= 70) return 'C';
237 if (score >= 50) return 'D';
238 return 'F';
239}
240
241function calculateRisk(violations: Violation[]): string {
242 const critical = violations.filter(v => v.impact === 'critical').length;
243 const serious = violations.filter(v => v.impact === 'serious').length;
244 const total = violations.length;
245 if (critical >= 5 || total >= 20) return 'Critical';
246 if (critical >= 2 || serious >= 5 || total >= 10) return 'High';
247 if (serious >= 2 || total >= 5) return 'Medium';
248 return 'Low';
249}
250
251function calculateScore(violations: Violation[]): number {
252 const weights: Record<string, number> = { critical: 25, serious: 10, moderate: 5, minor: 1 };
253 let penalty = 0;
254 for (const v of violations) {
255 penalty += weights[v.impact] || 1;
256 }
257 return Math.max(0, Math.min(100, 100 - penalty));
258}
259
260
261
262async function generateCodeSnippet(
263 violation: Violation,
264 llmEndpoint: string,
265 llmKey: string,
266 model: string
267): Promise<string> {
268 const firstNode = violation.nodes[0];
269 const prompt = `You are an accessibility expert. Generate a code snippet to fix this WCAG violation.
270
271Rule: ${violation.ruleId}
272Description: ${violation.description}
273Help: ${violation.help}
274Impact: ${violation.impact}
275
276Affected element:
277- HTML: ${firstNode.html.substring(0, 300)}
278- CSS Selector: ${firstNode.target.join(' ')}
279- Failure: ${firstNode.failureSummary.substring(0, 200)}
280
281Return ONLY the code snippet (HTML or CSS) that would fix this violation. No explanation. No markdown fences. Just the code.`;
282
283 if (!llmEndpoint || !llmKey) {
284 log.info(`LLM disabled — no endpoint or key. Using fallback for ${violation.ruleId}`);
285 return generateFallbackSnippet(violation);
286 }
287
288 try {
289 log.info(`Calling LLM: ${llmEndpoint}/chat/completions model=${model} for ${violation.ruleId}`);
290 const resp = await fetch(`${llmEndpoint}/chat/completions`, {
291 method: 'POST',
292 headers: {
293 'Authorization': `Bearer ${llmKey}`,
294 'Content-Type': 'application/json',
295 },
296 body: JSON.stringify({
297 model,
298 messages: [{ role: 'user', content: prompt }],
299 stream: false,
300 max_tokens: 4096,
301 thinking: { type: 'disabled' },
302 }),
303 });
304
305 if (!resp.ok) {
306 const errBody = await resp.text();
307 log.warning(`LLM API returned ${resp.status}: ${errBody.substring(0, 200)}`);
308 return generateFallbackSnippet(violation);
309 }
310
311 const data = await resp.json() as any;
312 const snippet = data?.choices?.[0]?.message?.content?.trim() || '';
313 if (snippet.length === 0) {
314 log.warning(`LLM returned empty snippet for ${violation.ruleId}`);
315 return generateFallbackSnippet(violation);
316 }
317 log.info(`LLM generated snippet (${snippet.length} chars) for ${violation.ruleId}`);
318 return snippet;
319 } catch (error) {
320 log.warning(`LLM snippet generation failed for ${violation.ruleId}: ${(error as Error).message}`);
321 return generateFallbackSnippet(violation);
322 }
323}
324
325function generateFallbackSnippet(violation: Violation): string {
326 const node = violation.nodes[0];
327 const target = node.target.join(' ');
328
329 switch (violation.ruleId) {
330 case 'html-has-lang':
331 return '<html lang="en">';
332 case 'image-alt':
333 return `<!-- Add alt text to images -->\n<img src="..." alt="Describe this image">`;
334 case 'html-valid-lang':
335 return '<html lang="en">';
336 case 'label':
337 return `<label for="input-id">Field label</label>\n<input id="input-id" type="text" name="field">`;
338 case 'link-name':
339 case 'button-name':
340 case 'input-button-name':
341 return `<!-- Add visible text or aria-label -->\n<a href="..." aria-label="Descriptive text"></a>`;
342 case 'color-contrast':
343 return `/* Increase contrast: minimum 4.5:1 for normal text */\n${target.split('>').pop() || 'element'} {\n color: #595959 !important;\n}`;
344 case 'duplicate-id':
345 return `<!-- Remove or rename duplicate ID -->\n<!-- Change id="..." to id="...-2" -->`;
346 case 'empty-heading':
347 return `<!-- Add meaningful text to heading -->\n<h2>Section Title</h2>`;
348 case 'target-size':
349 return `/* Ensure touch targets are at least 24px */\n${target.split('>').pop() || 'element'} {\n min-width: 24px;\n min-height: 24px;\n}`;
350 case 'bypass':
351 return `<!-- Add skip link at top of body -->\n<a href="#main-content" class="skip-link">Skip to main content</a>`;
352 case 'heading-order':
353 return `<!-- Fix heading hierarchy: h1 > h2 > h3. Do not skip levels -->`;
354 case 'landmark-main':
355 return `<main id="main-content">\n <!-- Main content here -->\n</main>`;
356 case 'meta-viewport':
357 return '<meta name="viewport" content="width=device-width, initial-scale=1">';
358 case 'frame-title':
359 return `<iframe src="..." title="Descriptive frame title"></iframe>`;
360 case 'list':
361 case 'listitem':
362 return `<!-- Use proper list semantics -->\n<ul>\n <li>Item 1</li>\n <li>Item 2</li>\n</ul>`;
363 case 'focus-visible':
364 return `/* Add visible focus indicator */\n${target.split('>').pop() || 'element'}:focus {\n outline: 2px solid #0066cc;\n outline-offset: 2px;\n}`;
365 default:
366 return `<!-- See remediation guide: ${violation.helpUrl} -->`;
367 }
368}
369
370
371
372async function scanPage(
373 browser: Browser,
374 url: string,
375 timeout: number = 30000
376): Promise<ScanPageResult> {
377 const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
378 const page = await context.newPage();
379
380 try {
381 await page.goto(url, { timeout, waitUntil: 'domcontentloaded' });
382 await page.waitForSelector('body', { timeout: 5000 });
383 await page.waitForTimeout(500);
384
385 const axeTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
386 const axeBuilder = new AxeBuilder({ page: page as any })
387 .withTags(axeTags)
388 .options({
389 runOnly: { type: 'tag', values: axeTags },
390 resultTypes: ['violations', 'passes', 'incomplete'],
391 });
392
393 const axeResult: AxeResults = await axeBuilder.analyze();
394 const pageTitle = await page.title();
395
396 const violations: Violation[] = axeResult.violations.map(v => ({
397 ruleId: v.id,
398 description: v.description,
399 impact: (v.impact || 'minor') as 'critical' | 'serious' | 'moderate' | 'minor',
400 tags: v.tags,
401 help: v.help,
402 helpUrl: v.helpUrl,
403 nodes: v.nodes.map(n => ({
404 target: n.target as string[],
405 html: n.html,
406 failureSummary: n.failureSummary || '',
407 })),
408 }));
409
410 const score = calculateScore(violations);
411 const risk = calculateRisk(violations);
412
413 return {
414 url,
415 pageTitle: pageTitle || url,
416 scanDate: new Date().toISOString(),
417 summary: {
418 totalViolations: violations.length,
419 critical: violations.filter(v => v.impact === 'critical').length,
420 serious: violations.filter(v => v.impact === 'serious').length,
421 moderate: violations.filter(v => v.impact === 'moderate').length,
422 minor: violations.filter(v => v.impact === 'minor').length,
423 passes: axeResult.passes.length,
424 incomplete: axeResult.incomplete.length,
425 complianceScore: score,
426 grade: calculateGrade(score),
427 lawsuitRisk: risk,
428 },
429 violations,
430 passes: axeResult.passes.map(p => ({ ruleId: p.id, description: p.description })),
431 incomplete: axeResult.incomplete.map(i => ({
432 ruleId: i.id,
433 description: i.description,
434 impact: i.impact || 'unknown',
435 })),
436 };
437 } finally {
438 await context.close();
439 }
440}
441
442
443
444const BUSINESS_IMPACT_RULES: Record<string, { score: number; reason: string }> = {
445 'color-contrast': { score: 15, reason: 'Affects all visually impaired users — most common ADA lawsuit trigger' },
446 'image-alt': { score: 15, reason: 'Screen reader users cannot navigate — #1 WCAG complaint in demand letters' },
447 'html-has-lang': { score: 12, reason: 'Screen readers use wrong language — affects entire page comprehension' },
448 'label': { score: 14, reason: 'Forms unusable for screen reader users — blocks conversions/bookings' },
449 'button-name': { score: 13, reason: 'Critical CTAs invisible to assistive tech — blocks user actions' },
450 'link-name': { score: 12, reason: 'Navigation impossible — links invisible to screen readers' },
451 'keyboard': { score: 20, reason: 'Keyboard users locked out — motor disability discrimination' },
452 'focus-visible': { score: 12, reason: 'Keyboard users cannot see where they are — common lawsuit trigger' },
453 'bypass': { score: 10, reason: 'No skip link — keyboard users must tab through entire menu' },
454 'tabindex': { score: 10, reason: 'Tab order is illogical — keyboard navigation broken' },
455 'aria-roles': { score: 8, reason: 'ARIA roles wrong — screen readers misinterpret content' },
456 'landmark-main': { score: 8, reason: 'No main landmark — screen reader navigation difficult' },
457 'heading-order': { score: 8, reason: 'Heading hierarchy broken — screen reader navigation confusing' },
458 'meta-viewport': { score: 10, reason: 'Zoom disabled — low-vision users cannot read content' },
459 'target-size': { score: 8, reason: 'Touch targets too small — motor-impaired users cannot click' },
460 'duplicate-id': { score: 5, reason: 'Assistive tech confusion — ARIA references break' },
461 'empty-heading': { score: 4, reason: 'Empty headings confuse screen reader navigation' },
462 'region': { score: 6, reason: 'Content not in regions — screen reader navigation difficult' },
463 'frame-title': { score: 8, reason: 'Iframes untitled — screen readers cannot describe embedded content' },
464 'list': { score: 4, reason: 'List markup wrong — screen readers cannot navigate lists' },
465 'listitem': { score: 4, reason: 'List items malformed — screen readers cannot navigate' },
466 'page-has-heading-one': { score: 6, reason: 'No primary heading — screen reader navigation unclear' },
467 'autocomplete-valid': { score: 3, reason: 'Autocomplete attributes wrong — affects form usability' },
468 'video-caption': { score: 15, reason: 'No captions — deaf users cannot access video content (high lawsuit risk)' },
469};
470
471function prioritizeByBusinessImpact(items: RemediationItem[]): RemediationItem[] {
472 return items.map(item => {
473 const impact = BUSINESS_IMPACT_RULES[item.ruleId] || { score: 5, reason: 'General accessibility violation' };
474
475 const severityWeight: Record<string, number> = { critical: 20, serious: 12, moderate: 6, minor: 2 };
476 const severityScore = severityWeight[item.impact] || 2;
477 const businessScore = impact.score;
478
479 return {
480 ...item,
481
482 businessImpactScore: businessScore,
483 businessImpactReason: impact.reason,
484 priorityScore: Math.round((businessScore * 0.6 + severityScore * 0.4) * 10) / 10,
485 } as RemediationItem & { businessImpactScore: number; businessImpactReason: string; priorityScore: number };
486 }).sort((a, b) => {
487 const aScore = (a as any).priorityScore || 0;
488 const bScore = (b as any).priorityScore || 0;
489 return bScore - aScore;
490 });
491}
492
493
494
495async function buildRemediationReport(
496 scanResult: ScanPageResult,
497 llmEndpoint: string,
498 llmKey: string,
499 model: string
500): Promise<PageRemediationReport> {
501 const remediationItems: RemediationItem[] = [];
502
503 for (const violation of scanResult.violations) {
504 const elementCount = violation.nodes.length;
505 const cost = getCostEstimate(violation.ruleId, elementCount);
506 const snippet = await generateCodeSnippet(violation, llmEndpoint, llmKey, model);
507
508 remediationItems.push({
509 ruleId: violation.ruleId,
510 impact: violation.impact,
511 plainEnglish: getPlainEnglish(violation.ruleId, violation.description),
512 technicalDescription: violation.description,
513 affectedElements: elementCount,
514 codeSnippet: snippet,
515 costLow: cost.low,
516 costHigh: cost.high,
517 estimatedHours: Math.round(cost.hours * 10) / 10,
518 helpUrl: violation.helpUrl,
519 examples: violation.nodes.slice(0, 3).map(n => ({
520 target: n.target,
521 html: n.html.substring(0, 200),
522 failureSummary: n.failureSummary.substring(0, 200),
523 })),
524 });
525 }
526
527 const totalCostLow = remediationItems.reduce((s, i) => s + i.costLow, 0);
528 const totalCostHigh = remediationItems.reduce((s, i) => s + i.costHigh, 0);
529 const totalHours = remediationItems.reduce((s, i) => s + i.estimatedHours, 0);
530
531 const hasCritical = remediationItems.some(i => i.impact === 'critical');
532 const hasSerious = remediationItems.some(i => i.impact === 'serious');
533 const fixByImpact = hasCritical ? 'critical' : hasSerious ? 'serious' : 'moderate';
534 const recommendedFixBy = getRecommendedFixBy(scanResult.scanDate, fixByImpact);
535
536 return {
537 url: scanResult.url,
538 pageTitle: scanResult.pageTitle,
539 scanDate: scanResult.scanDate,
540 remediationSummary: {
541 totalCostLow: Math.round(totalCostLow),
542 totalCostHigh: Math.round(totalCostHigh),
543 totalHours: Math.round(totalHours * 10) / 10,
544 criticalCount: scanResult.summary.critical,
545 seriousCount: scanResult.summary.serious,
546 moderateCount: scanResult.summary.moderate,
547 minorCount: scanResult.summary.minor,
548 recommendedFixBy,
549 grade: scanResult.summary.grade,
550 lawsuitRisk: scanResult.summary.lawsuitRisk,
551 complianceScore: scanResult.summary.complianceScore,
552 },
553 remediationItems,
554 itemsByPriority: {
555 critical: remediationItems.filter(i => i.impact === 'critical'),
556 serious: remediationItems.filter(i => i.impact === 'serious'),
557 moderate: remediationItems.filter(i => i.impact === 'moderate'),
558 minor: remediationItems.filter(i => i.impact === 'minor'),
559 },
560 };
561}
562
563function buildMarkdownReport(output: ActorOutput): string {
564 const lines: string[] = [];
565
566 lines.push('# ADA/WCAG Remediation Report');
567 lines.push('');
568 lines.push(`**Generated:** ${output.reportDate}`);
569 lines.push(`**Pages scanned:** ${output.totalPages}`);
570 lines.push(`**Overall grade:** ${output.overallGrade}`);
571 lines.push(`**Lawsuit risk:** ${output.overallRisk}`);
572 lines.push(`**Estimated remediation cost:** $${output.totalCostLow} - $${output.totalCostHigh}`);
573 lines.push(`**Estimated hours:** ${output.totalHours}`);
574 lines.push(`**Recommended fix-by:** ${output.recommendedFixBy}`);
575 lines.push('');
576 lines.push('---');
577 lines.push('');
578
579 if (output.competitors && output.competitors.length > 0) {
580 lines.push('## Competitor Benchmark');
581 lines.push('');
582 lines.push('| Site | Score | Grade | Risk | Violations | Critical | Serious |');
583 lines.push('|------|-------|-------|------|------------|----------|---------|');
584 for (const c of output.competitors) {
585 lines.push(`| ${c.url} | ${c.score} | ${c.grade} | ${c.risk} | ${c.totalViolations} | ${c.critical} | ${c.serious} |`);
586 }
587 lines.push('');
588 lines.push('---');
589 lines.push('');
590 }
591
592 for (const report of output.reports) {
593 lines.push(`## ${report.pageTitle}`);
594 lines.push(`**URL:** ${report.url}`);
595 lines.push('');
596 lines.push(`| Metric | Value |`);
597 lines.push(`|--------|-------|`);
598 lines.push(`| Compliance score | ${report.remediationSummary.complianceScore}/100 (Grade ${report.remediationSummary.grade}) |`);
599 lines.push(`| Lawsuit risk | ${report.remediationSummary.lawsuitRisk} |`);
600 lines.push(`| Critical violations | ${report.remediationSummary.criticalCount} |`);
601 lines.push(`| Serious violations | ${report.remediationSummary.seriousCount} |`);
602 lines.push(`| Moderate violations | ${report.remediationSummary.moderateCount} |`);
603 lines.push(`| Minor violations | ${report.remediationSummary.minorCount} |`);
604 lines.push(`| Est. remediation cost | $${report.remediationSummary.totalCostLow} - $${report.remediationSummary.totalCostHigh} |`);
605 lines.push(`| Est. hours | ${report.remediationSummary.totalHours} |`);
606 lines.push(`| Recommended fix-by | ${report.remediationSummary.recommendedFixBy} |`);
607 lines.push('');
608
609 const priorities: [string, RemediationItem[]][] = [
610 ['Critical (fix within 14 days)', report.itemsByPriority.critical],
611 ['Serious (fix within 30 days)', report.itemsByPriority.serious],
612 ['Moderate (fix within 90 days)', report.itemsByPriority.moderate],
613 ['Minor (fix within 180 days)', report.itemsByPriority.minor],
614 ];
615
616 for (const [label, items] of priorities) {
617 if (items.length === 0) continue;
618 lines.push(`### ${label}`);
619 lines.push('');
620 for (const item of items) {
621 lines.push(`#### ${item.ruleId}: ${item.plainEnglish}`);
622 lines.push(`- **Impact:** ${item.impact}`);
623 lines.push(`- **Affected elements:** ${item.affectedElements}`);
624 lines.push(`- **Cost:** $${item.costLow} - $${item.costHigh}`);
625 lines.push(`- **Hours:** ${item.estimatedHours}`);
626 lines.push(`- **Technical:** ${item.technicalDescription}`);
627 lines.push(`- **Code fix:**`);
628 lines.push('```');
629 lines.push(item.codeSnippet);
630 lines.push('```');
631 if (item.examples.length > 0) {
632 lines.push(`- **Examples:**`);
633 for (const ex of item.examples) {
634 lines.push(` - Selector: \`${ex.target.join(' ')}\``);
635 lines.push(` - Failure: ${ex.failureSummary.substring(0, 100)}`);
636 }
637 }
638 lines.push(`- [Remediation guide](${item.helpUrl})`);
639 lines.push('');
640 }
641 }
642 lines.push('---');
643 lines.push('');
644 }
645
646 lines.push('## Disclaimer');
647 lines.push('');
648 lines.push('This report is generated automatically using axe-core (90+ WCAG rules) and AI-assisted code snippet generation. Automated testing captures ~30-40% of WCAG violations. Manual testing is recommended for full compliance. This report does not constitute legal advice.');
649
650 return lines.join('\n');
651}
652
653
654
655async function main() {
656 await Actor.init();
657
658 const input = (await Actor.getInput()) as ActorInput;
659
660 if (!input) {
661 log.error('No input provided');
662 await Actor.exit('No input provided', { exitCode: 1 });
663 return;
664 }
665
666 const reportFormat = input.reportFormat || 'json';
667 const llmModel = input.llmModel || 'deepseek-v4-flash';
668 const llmEndpoint = process.env.LLM_ENDPOINT || '';
669 const llmKey = process.env.LLM_API_KEY || process.env.OMNIROUTE_API_KEY || '';
670
671 let scanResults: ScanPageResult[] = [];
672
673 if (input.scanResult && input.scanResult.length > 0) {
674 log.info(`Using pre-existing scan results (${input.scanResult.length} pages)`);
675 scanResults = input.scanResult;
676 } else if (input.startUrls && input.startUrls.length > 0) {
677 log.info(`Scanning ${input.startUrls.length} URLs with axe-core`);
678 const browser = await chromium.launch({
679 headless: true,
680 args: ['--disable-gpu', '--no-sandbox', '--disable-setuid-sandbox'],
681 });
682
683 try {
684 for (const { url } of input.startUrls) {
685 log.info(`Scanning: ${url}`);
686 const result = await scanPage(browser, url);
687 scanResults.push(result);
688 log.info(` Score: ${result.summary.complianceScore} Grade: ${result.summary.grade} Violations: ${result.summary.totalViolations}`);
689 }
690 } finally {
691 await browser.close();
692 }
693 } else {
694 log.error('No startUrls or scanResult provided');
695 await Actor.exit('No input: provide startUrls or scanResult', { exitCode: 1 });
696 return;
697 }
698
699 log.info('Generating remediation reports with LLM enrichment...');
700 const reports: PageRemediationReport[] = [];
701
702 const prioritize = input.prioritizeByImpact || false;
703 if (prioritize) log.info('Business impact prioritization ENABLED — ranking violations by lawsuit risk + user impact');
704
705 for (const scanResult of scanResults) {
706 const report = await buildRemediationReport(scanResult, llmEndpoint, llmKey, llmModel);
707
708
709 if (prioritize) {
710 report.remediationItems = prioritizeByBusinessImpact(report.remediationItems) as RemediationItem[];
711
712 report.itemsByPriority = {
713 critical: report.remediationItems.filter(i => i.impact === 'critical'),
714 serious: report.remediationItems.filter(i => i.impact === 'serious'),
715 moderate: report.remediationItems.filter(i => i.impact === 'moderate'),
716 minor: report.remediationItems.filter(i => i.impact === 'minor'),
717 };
718 log.info(` Prioritized ${report.remediationItems.length} items by business impact`);
719 }
720
721 reports.push(report);
722
723 await Actor.charge({ eventName: 'REPORT', count: 1 });
724 await Actor.pushData(report);
725
726 log.info(
727 `Report: ${report.url} — Cost: $${report.remediationSummary.totalCostLow}-$${report.remediationSummary.totalCostHigh} ` +
728 `Hours: ${report.remediationSummary.totalHours} Fix-by: ${report.remediationSummary.recommendedFixBy}`
729 );
730 }
731
732 let competitors: CompetitorResult[] | undefined;
733
734 if (input.competitorUrls && input.competitorUrls.length > 0) {
735 log.info(`Scanning ${input.competitorUrls.length} competitor URLs`);
736 const browser = await chromium.launch({
737 headless: true,
738 args: ['--disable-gpu', '--no-sandbox', '--disable-setuid-sandbox'],
739 });
740
741 try {
742 competitors = [];
743 for (const { url } of input.competitorUrls) {
744 log.info(`Competitor scan: ${url}`);
745 const result = await scanPage(browser, url);
746 competitors.push({
747 url: result.url,
748 pageTitle: result.pageTitle,
749 score: result.summary.complianceScore,
750 grade: result.summary.grade,
751 risk: result.summary.lawsuitRisk,
752 totalViolations: result.summary.totalViolations,
753 critical: result.summary.critical,
754 serious: result.summary.serious,
755 });
756 }
757 } finally {
758 await browser.close();
759 }
760 }
761
762 const totalCostLow = reports.reduce((s, r) => s + r.remediationSummary.totalCostLow, 0);
763 const totalCostHigh = reports.reduce((s, r) => s + r.remediationSummary.totalCostHigh, 0);
764 const totalHours = reports.reduce((s, r) => s + r.remediationSummary.totalHours, 0);
765 const allScores = reports.map(r => r.remediationSummary.complianceScore);
766 const overallScore = allScores.length > 0
767 ? Math.round(allScores.reduce((a, b) => a + b, 0) / allScores.length)
768 : 0;
769 const allViolations = reports.flatMap(r => r.remediationItems);
770 const hasCritical = allViolations.some(v => v.impact === 'critical');
771 const overallFixBy = getRecommendedFixBy(
772 new Date().toISOString(),
773 hasCritical ? 'critical' : 'serious'
774 );
775
776 const output: ActorOutput = {
777 reportDate: new Date().toISOString(),
778 totalPages: reports.length,
779 totalCostLow: Math.round(totalCostLow),
780 totalCostHigh: Math.round(totalCostHigh),
781 totalHours: Math.round(totalHours * 10) / 10,
782 overallGrade: calculateGrade(overallScore),
783 overallRisk: calculateRisk(
784 scanResults.flatMap(s => s.violations)
785 ),
786 recommendedFixBy: overallFixBy,
787 reports,
788 competitors,
789 };
790
791 if (reportFormat === 'markdown' || reportFormat === 'both') {
792 output.markdownReport = buildMarkdownReport(output);
793 }
794
795 log.info(
796 `Report complete: ${output.totalPages} pages, ` +
797 `Cost: $${output.totalCostLow}-$${output.totalCostHigh}, ` +
798 `Hours: ${output.totalHours}, Grade: ${output.overallGrade}, ` +
799 `Fix-by: ${output.recommendedFixBy}`
800 );
801
802 const kvStore = await Actor.openKeyValueStore();
803 await kvStore.setValue('OUTPUT', output);
804
805 await Actor.exit();
806}
807
808main().catch(async (error) => {
809 console.error('Fatal error:', error);
810 await Actor.exit('Fatal error', { exitCode: 1 });
811});