UNPKG

n8n-nodes-pdf-accessibility

Version:

AI-powered PDF accessibility automation for N8N - comprehensive WCAG compliance analysis, intelligent remediation, and professional audit reporting with 5 integrated accessibility tools

344 lines (343 loc) 14.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.LinkTextTool = void 0; class LinkTextTool { getName() { return 'link_text'; } getDescription() { return 'Analyzes and improves link text for meaningful descriptions and accessibility'; } getSupportedWCAGCriteria() { return [ '2.4.4', // Link Purpose (In Context) (Level A) '2.4.9', // Link Purpose (Link Only) (Level AAA) '3.2.4', // Consistent Identification (Level AA) ]; } canProcess(context) { return context.hasLinks; } async execute(context, llmProvider) { const startTime = Date.now(); const issues = []; const fixes = []; try { // Analyze link structure const linkAnalysis = await this.analyzeLinkStructure(context); if (linkAnalysis.links.length === 0) { return { toolName: this.getName(), success: true, issuesFound: [], fixesApplied: [], processing_time_ms: Date.now() - startTime, }; } // Identify accessibility issues const linkIssues = this.identifyLinkIssues(linkAnalysis, context); issues.push(...linkIssues); // Generate fixes if LLM provider is available if (llmProvider && issues.length > 0) { const linkFixes = await this.generateLinkFixes(linkAnalysis, context, llmProvider); fixes.push(...linkFixes); } return { toolName: this.getName(), success: true, issuesFound: issues, fixesApplied: fixes, processing_time_ms: Date.now() - startTime, }; } catch (error) { return { toolName: this.getName(), success: false, issuesFound: issues, fixesApplied: fixes, processing_time_ms: Date.now() - startTime, error: error instanceof Error ? error.message : String(error), }; } } async analyzeLinkStructure(context) { const links = this.extractLinkInfo(context); const genericLinks = links.filter(link => this.isGenericLinkText(link.text)); const undescriptiveLinks = links.filter(link => !link.isDescriptive); const duplicateTexts = this.findDuplicateLinkTexts(links); const linksWithoutContext = links.filter(link => link.contextText.trim().length < 20); const externalLinksWithoutWarning = links.filter(link => link.isExternal && !this.hasExternalWarning(link)); return { links, genericLinks, undescriptiveLinks, duplicateTexts, linksWithoutContext, externalLinksWithoutWarning, }; } extractLinkInfo(context) { const links = []; const text = context.textContent; // Extract different types of links const patterns = [ // URLs { regex: /https?:\/\/[^\s]+/gi, type: 'url' }, // Email addresses { regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/gi, type: 'email' }, // Phone numbers { regex: /(?:\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}/gi, type: 'phone' }, // Generic link patterns { regex: /\b(?:click here|read more|learn more|more info|download|link)\b/gi, type: 'generic' } ]; let position = 0; const lines = text.split('\n'); for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const line = lines[lineIndex]; for (const pattern of patterns) { let match; const regex = new RegExp(pattern.regex.source, pattern.regex.flags); while ((match = regex.exec(line)) !== null) { const linkText = match[0]; const linkInfo = { text: linkText, url: pattern.type === 'url' ? linkText : undefined, page: Math.ceil(position / 3000), position: position + match.index, contextText: this.extractLinkContext(lines, lineIndex, match.index), isDescriptive: this.isDescriptiveLinkText(linkText), isGeneric: this.isGenericLinkText(linkText), isEmail: pattern.type === 'email', isPhone: pattern.type === 'phone', isExternal: pattern.type === 'url' && !this.isInternalLink(linkText), hasTitle: false, // Would need PDF structure analysis }; links.push(linkInfo); } } position += line.length; } return links.slice(0, 50); // Limit to first 50 links } extractLinkContext(lines, lineIndex, charIndex) { const currentLine = lines[lineIndex]; const beforeText = currentLine.substring(Math.max(0, charIndex - 50), charIndex); const afterText = currentLine.substring(charIndex, Math.min(currentLine.length, charIndex + 100)); // Also include surrounding lines for context const prevLine = lineIndex > 0 ? lines[lineIndex - 1].substring(-30) : ''; const nextLine = lineIndex < lines.length - 1 ? lines[lineIndex + 1].substring(0, 30) : ''; return `${prevLine} ${beforeText} [LINK] ${afterText} ${nextLine}`.trim(); } isDescriptiveLinkText(text) { // Check if link text is descriptive enough const words = text.trim().split(/\s+/); // Too short or generic if (words.length < 2 || text.length < 4) return false; // Generic phrases const genericPhrases = [ 'click here', 'read more', 'learn more', 'more info', 'info', 'download', 'link', 'here', 'this', 'more', 'continue' ]; const lowerText = text.toLowerCase(); return !genericPhrases.some(phrase => lowerText.includes(phrase)); } isGenericLinkText(text) { const genericPhrases = [ 'click here', 'read more', 'learn more', 'more info', 'more information', 'download', 'link', 'here', 'this link', 'continue', 'next', 'previous' ]; const lowerText = text.toLowerCase().trim(); return genericPhrases.includes(lowerText) || genericPhrases.some(phrase => lowerText === phrase); } isInternalLink(url) { // Simple heuristic - would need actual domain analysis return url.includes('#') || url.startsWith('/') || url.includes('localhost'); } hasExternalWarning(link) { const contextLower = link.contextText.toLowerCase(); const warningWords = ['external', 'opens in new', 'new window', 'new tab', 'leaves site']; return warningWords.some(word => contextLower.includes(word)); } findDuplicateLinkTexts(links) { const textCounts = new Map(); links.forEach(link => { const normalizedText = link.text.toLowerCase().trim(); textCounts.set(normalizedText, (textCounts.get(normalizedText) || 0) + 1); }); return Array.from(textCounts.entries()) .filter(([text, count]) => count > 1 && text.length > 3) .map(([text]) => text); } identifyLinkIssues(analysis, _context) { const issues = []; // Generic link text if (analysis.genericLinks.length > 0) { issues.push({ type: 'link_text', severity: 'high', description: `Found ${analysis.genericLinks.length} links with generic text like "click here" or "read more"`, wcagCriteria: ['2.4.4', '2.4.9'], suggestion: 'Replace generic link text with descriptive text that explains the link destination or purpose', }); } // Undescriptive links if (analysis.undescriptiveLinks.length > 0) { issues.push({ type: 'link_text', severity: 'medium', description: `Found ${analysis.undescriptiveLinks.length} links with insufficient descriptive text`, wcagCriteria: ['2.4.4'], suggestion: 'Improve link text to be more descriptive of the destination or action', }); } // Duplicate link texts if (analysis.duplicateTexts.length > 0) { issues.push({ type: 'link_text', severity: 'medium', description: `Found ${analysis.duplicateTexts.length} sets of duplicate link text`, wcagCriteria: ['3.2.4'], suggestion: 'Ensure identical link text leads to the same destination, or make text unique for different destinations', }); } // External links without warning if (analysis.externalLinksWithoutWarning.length > 0) { issues.push({ type: 'link_text', severity: 'low', description: `Found ${analysis.externalLinksWithoutWarning.length} external links without clear indication`, wcagCriteria: ['3.2.1'], suggestion: 'Indicate external links clearly to inform users they will leave the current context', }); } // Links without sufficient context if (analysis.linksWithoutContext.length > 0) { issues.push({ type: 'link_text', severity: 'medium', description: `Found ${analysis.linksWithoutContext.length} links lacking sufficient context`, wcagCriteria: ['2.4.4'], suggestion: 'Ensure links have sufficient surrounding context or improve the link text itself', }); } return issues; } async generateLinkFixes(analysis, context, llmProvider) { const fixes = []; // Fix generic links for (const link of analysis.genericLinks.slice(0, 10)) { // Limit to first 10 try { const improvedText = await this.generateImprovedLinkText(link, context, llmProvider); fixes.push({ type: 'link_text_improvement', description: `Improved generic link text`, applied: false, wcagImprovement: ['2.4.4', '2.4.9'], beforeValue: link.text, afterValue: improvedText, }); } catch (error) { console.warn(`Failed to improve link text for "${link.text}":`, error); } } // Fix undescriptive links for (const link of analysis.undescriptiveLinks.slice(0, 10)) { // Limit to first 10 if (!analysis.genericLinks.includes(link)) { // Skip if already processed try { const improvedText = await this.generateImprovedLinkText(link, context, llmProvider); fixes.push({ type: 'link_text_improvement', description: `Enhanced link descriptiveness`, applied: false, wcagImprovement: ['2.4.4'], beforeValue: link.text, afterValue: improvedText, }); } catch (error) { console.warn(`Failed to improve link text for "${link.text}":`, error); } } } // Generate suggestions for duplicate link texts if (analysis.duplicateTexts.length > 0) { fixes.push({ type: 'link_text_uniqueness', description: `Identified ${analysis.duplicateTexts.length} duplicate link texts requiring attention`, applied: false, wcagImprovement: ['3.2.4'], beforeValue: analysis.duplicateTexts.join(', '), afterValue: 'Review duplicate link texts to ensure they lead to the same destination or make them unique', }); } return fixes; } async generateImprovedLinkText(link, _context, _llmProvider) { // Mock implementation - would use LLM in real scenario const contextWords = link.contextText.toLowerCase(); // Analyze context to suggest better link text if (link.isEmail) { return `Email ${link.text}`; } else if (link.isPhone) { return `Call ${link.text}`; } else if (link.url) { const domain = this.extractDomain(link.url); return `Visit ${domain}`; } else if (contextWords.includes('download')) { return 'Download document'; } else if (contextWords.includes('report') || contextWords.includes('study')) { return 'View full report'; } else if (contextWords.includes('contact') || contextWords.includes('support')) { return 'Contact support'; } else if (contextWords.includes('product') || contextWords.includes('service')) { return 'Learn about our services'; } else if (contextWords.includes('policy') || contextWords.includes('terms')) { return 'Read privacy policy'; } else { // Extract key words from context for generic improvement const keywords = this.extractKeywords(contextWords); return keywords.length > 0 ? `Learn more about ${keywords[0]}` : 'Learn more details'; } } extractDomain(url) { try { const match = url.match(/https?:\/\/([^\/]+)/); return match ? match[1] : 'external site'; } catch { return 'external site'; } } extractKeywords(text) { const words = text.split(/\s+/); const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by']); return words .filter(word => word.length > 3 && !stopWords.has(word.toLowerCase())) .slice(0, 3); } } exports.LinkTextTool = LinkTextTool;