a11yanalyze
Version:
A command-line tool for developers and QA engineers to test web pages and websites for WCAG 2.2 AA accessibility compliance
875 lines (755 loc) • 23.7 kB
text/typescript
import { EventEmitter } from 'events';
import { URL } from 'url';
import { PageScanner } from './page-scanner';
import {
CrawlerConfig,
CrawlSession,
URLEntry,
URLStatus,
CrawlStatus,
CrawlEvent,
CrawlEventType,
URLDiscoveryResult,
URLDiscoverySource,
CrawlProgress,
CrawlStats,
SitemapInfo,
RobotsInfo
} from '../types/crawler';
import { ScanResult, ScanOptions, BrowserConfig, RuleEngineConfig } from '../types';
import { WCAGLevelConfig } from './wcag-level-handler';
import { ResilienceConfig } from './error-resilience';
/**
* Site crawler for multi-page accessibility scanning
* Provides crawling capabilities with depth limits, domain restrictions, and rate limiting
*/
export class SiteCrawler extends EventEmitter {
private pageScanner: PageScanner;
private session: CrawlSession | null = null;
private isRunning = false;
private isPaused = false;
private abortController: AbortController | null = null;
private activeRequests = new Set<string>();
private requestQueue: URLEntry[] = [];
private lastRequestTime = 0;
private robotsCache = new Map<string, RobotsInfo>();
private sitemapCache = new Map<string, SitemapInfo>();
constructor(
browserConfig?: BrowserConfig,
ruleEngineConfig?: RuleEngineConfig,
resilienceConfig?: Partial<ResilienceConfig>,
wcagLevelConfig?: Partial<WCAGLevelConfig>
) {
super();
this.pageScanner = new PageScanner(
browserConfig,
ruleEngineConfig,
resilienceConfig,
wcagLevelConfig
);
}
/**
* Start a new crawl session
*/
async startCrawl(
startUrls: string[],
config: Partial<CrawlerConfig> = {},
scanOptions: Partial<ScanOptions> = {}
): Promise<string> {
if (this.isRunning) {
throw new Error('Crawler is already running. Stop the current session first.');
}
// Validate and normalize start URLs
const validatedUrls = this.validateStartUrls(startUrls);
if (validatedUrls.length === 0) {
throw new Error('No valid start URLs provided');
}
// Create crawl session
const sessionId = this.generateSessionId();
const crawlerConfig = this.createDefaultConfig(config);
this.session = {
id: sessionId,
startUrls: validatedUrls,
config: crawlerConfig,
scanOptions,
startTime: new Date(),
status: 'initializing',
urls: new Map(),
results: new Map(),
stats: this.createInitialStats(),
// Add a recentErrors array to the session for tracking
recentErrors: []
} as CrawlSession & { recentErrors: string[] };
// Initialize scanner
await this.pageScanner.initialize();
// Set up session state
this.isRunning = true;
this.isPaused = false;
this.abortController = new AbortController();
// Emit session started event
this.emitEvent('session_started', { sessionId });
// Start crawling process
this.crawlAsync().catch(error => {
this.handleCrawlError(error);
});
return sessionId;
}
/**
* Pause the current crawl session
*/
async pauseCrawl(): Promise<void> {
if (!this.isRunning || this.isPaused) {
throw new Error('No active crawl session to pause');
}
this.isPaused = true;
if (this.session) {
this.session.status = 'paused';
this.emitEvent('session_paused');
}
}
/**
* Resume the paused crawl session
*/
async resumeCrawl(): Promise<void> {
if (!this.isRunning || !this.isPaused) {
throw new Error('No paused crawl session to resume');
}
this.isPaused = false;
if (this.session) {
this.session.status = 'crawling';
this.emitEvent('session_resumed');
}
}
/**
* Stop the current crawl session
*/
async stopCrawl(): Promise<void> {
if (!this.isRunning) {
return;
}
// Signal abortion
if (this.abortController) {
this.abortController.abort();
}
// Wait for active requests to complete
await this.waitForActiveRequests();
// Update session status only if not already completed or failed
if (this.session && this.session.status !== 'completed' && this.session.status !== 'failed') {
this.session.status = 'cancelled';
this.session.endTime = new Date();
this.emitEvent('session_failed', {
error: 'Crawl session was manually stopped'
});
}
// Clean up
await this.cleanup();
}
/**
* Get current crawl progress
*/
getProgress(): CrawlProgress | null {
if (!this.session) {
return null;
}
const stats = this.session.stats;
const urlCounts = stats.urlCounts;
const processedUrls = urlCounts.completed + urlCounts.failed + urlCounts.skipped;
const totalUrls = urlCounts.total;
const percentage = totalUrls > 0 ? Math.round((processedUrls / totalUrls) * 100) : 0;
// Calculate scan rate
const elapsed = Date.now() - this.session.startTime.getTime();
const scanRate = elapsed > 0 ? (stats.pagesScanned / (elapsed / 60000)) : 0;
// Estimate remaining time
const remainingUrls = urlCounts.pending + urlCounts.processing;
const estimatedTimeRemaining = scanRate > 0 ? (remainingUrls / scanRate) * 60000 : undefined;
return {
status: this.session.status,
percentage,
urlsProcessed: processedUrls,
totalUrls,
currentDepth: this.getCurrentDepth(),
scanRate,
estimatedTimeRemaining,
currentUrl: this.getCurrentUrl(),
recentErrors: this.getRecentErrors(),
};
}
/**
* Get current session information
*/
getSession(): CrawlSession | null {
return this.session ? { ...this.session } : null;
}
/**
* Main crawling process
* @private
*/
private async crawlAsync(): Promise<void> {
if (!this.session) {
throw new Error('No session initialized');
}
try {
// Initialize URLs from start URLs
await this.initializeUrls();
// Discover additional URLs from sitemaps and robots.txt
if (this.session.config.useSitemaps) {
await this.discoverFromSitemaps();
}
// Start the main crawling loop
this.session.status = 'crawling';
await this.crawlUrls();
// Complete session
this.session.status = 'completed';
this.session.endTime = new Date();
this.emitEvent('session_completed');
} catch (error) {
this.handleCrawlError(error);
} finally {
await this.cleanup();
}
}
/**
* Initialize URLs from start URLs
* @private
*/
private async initializeUrls(): Promise<void> {
if (!this.session) return;
this.session.status = 'discovering';
for (const url of this.session.startUrls) {
const urlEntry: URLEntry = {
url,
depth: 0,
source: 'initial',
priority: 100, // High priority for start URLs
discoveredAt: new Date(),
attempts: 0,
status: 'pending',
};
this.session.urls.set(url, urlEntry);
this.requestQueue.push(urlEntry);
this.emitEvent('url_discovered', { url });
}
this.updateStats();
}
/**
* Main URL crawling loop
* @private
*/
private async crawlUrls(): Promise<void> {
if (!this.session) return;
// --- FIX: Always process all URLs in the queue, even after failures ---
while ((this.requestQueue.length > 0 || this.activeRequests.size > 0) && !this.abortController?.signal.aborted) {
// Wait if paused
if (this.isPaused) {
await this.sleep(1000);
continue;
}
// Check concurrent request limit
if (this.activeRequests.size >= this.session.config.maxConcurrency) {
await this.sleep(100);
continue;
}
// Get next URL to process
const urlEntry = this.getNextUrl();
if (!urlEntry) {
await this.sleep(100);
continue;
}
// Apply rate limiting
await this.applyRateLimit();
// Process URL (do not exit loop after failure)
this.processUrl(urlEntry);
}
// Wait for all active requests to complete
await this.waitForActiveRequests();
}
/**
* Process a single URL
* @private
*/
private async processUrl(urlEntry: URLEntry): Promise<void> {
if (!this.session) return;
const { url } = urlEntry;
try {
// Mark as processing
urlEntry.status = 'processing';
urlEntry.lastAttempt = new Date();
urlEntry.attempts++;
this.activeRequests.add(url);
this.emitEvent('url_started', { url });
// Check if we should process this URL
if (!this.shouldProcessUrl(urlEntry)) {
urlEntry.status = 'skipped';
this.emitEvent('url_skipped', { url });
return;
}
// Scan the page
const scanResult = await this.pageScanner.scan(url, this.session.scanOptions);
// Store result
this.session.results.set(url, scanResult);
urlEntry.status = 'completed';
this.emitEvent('url_completed', { url });
// Discover new URLs from this page
if (urlEntry.depth < this.session.config.maxDepth || this.session.config.maxDepth === -1) {
await this.discoverUrlsFromPage(scanResult, urlEntry);
}
// Update statistics
this.updateStatsForCompletedUrl(scanResult);
} catch (error) {
urlEntry.status = 'failed';
urlEntry.error = error instanceof Error ? error.message : String(error);
this.emitEvent('url_failed', { url, error: urlEntry.error });
// --- FIX: Always push every error to recentErrors, never skip or overwrite ---
if (this.session && 'recentErrors' in this.session) {
(this.session as any).recentErrors.push(urlEntry.error);
if ((this.session as any).recentErrors.length > 20) {
(this.session as any).recentErrors = (this.session as any).recentErrors.slice(-20);
}
}
} finally {
this.activeRequests.delete(url);
this.updateStats();
}
}
/**
* Discover URLs from a scanned page
* @private
*/
private async discoverUrlsFromPage(scanResult: ScanResult, parentEntry: URLEntry): Promise<void> {
if (!this.session) return;
try {
// Extract URLs from the page content
const discoveredUrls = await this.extractUrlsFromPage(scanResult.url);
for (const discoveredUrl of discoveredUrls) {
if (this.shouldAddUrl(discoveredUrl, parentEntry)) {
const urlEntry: URLEntry = {
url: discoveredUrl,
depth: parentEntry.depth + 1,
source: 'page',
parent: parentEntry.url,
priority: Math.max(1, parentEntry.priority - 10), // Decrease priority with depth
discoveredAt: new Date(),
attempts: 0,
status: 'pending',
};
this.session.urls.set(discoveredUrl, urlEntry);
this.requestQueue.push(urlEntry);
this.emitEvent('url_discovered', { url: discoveredUrl });
}
}
} catch (error) {
console.warn(`Failed to discover URLs from ${parentEntry.url}:`, error);
}
}
/**
* Extract URLs from a page
* @private
*/
private async extractUrlsFromPage(pageUrl: string): Promise<string[]> {
// This would normally use the page content to extract links
// For now, return empty array as we need the actual page content
// In a real implementation, this would parse the HTML content
return [];
}
/**
* Check if URL should be processed
* @private
*/
private shouldProcessUrl(urlEntry: URLEntry): boolean {
if (!this.session) return false;
const { config } = this.session;
const url = new URL(urlEntry.url);
// Check domain restrictions
if (config.allowedDomains.length > 0) {
if (!config.allowedDomains.includes(url.hostname)) {
return false;
}
}
// Check excluded domains
if (config.excludedDomains.some(domain => url.hostname.includes(domain))) {
return false;
}
// Check path patterns
if (config.excludedPaths.some(pattern => new RegExp(pattern).test(url.pathname))) {
return false;
}
if (config.includedPaths.length > 0) {
if (!config.includedPaths.some(pattern => new RegExp(pattern).test(url.pathname))) {
return false;
}
}
// --- FIX: Only enforce maxPages after all start URLs have been processed at least once ---
// If this is a start URL (depth 0, source 'initial'), always process it
if (urlEntry.depth === 0 && urlEntry.source === 'initial') {
return true;
}
// Check depth limit
if (config.maxDepth !== -1 && urlEntry.depth > config.maxDepth) {
this.emitEvent('depth_limit_reached', { url: urlEntry.url, depth: urlEntry.depth });
return false;
}
// Check page limit (for non-start URLs)
if (this.session.stats.pagesScanned >= config.maxPages) {
this.emitEvent('page_limit_reached', { url: urlEntry.url });
return false;
}
return true;
}
/**
* Check if URL should be added to crawl queue
* @private
*/
private shouldAddUrl(url: string, parentEntry: URLEntry): boolean {
if (!this.session) return false;
// Skip if already discovered
if (this.session.urls.has(url)) {
return false;
}
try {
const urlObj = new URL(url);
const { config } = this.session;
// Skip non-HTTP(S) URLs
if (!['http:', 'https:'].includes(urlObj.protocol)) {
return false;
}
// Check domain restrictions
if (config.allowedDomains.length > 0) {
if (!config.allowedDomains.includes(urlObj.hostname)) {
if (!config.discoverExternalLinks) {
return false;
}
}
}
return true;
} catch (error) {
// Invalid URL
return false;
}
}
/**
* Discover URLs from sitemaps
* @private
*/
private async discoverFromSitemaps(): Promise<void> {
if (!this.session) return;
for (const startUrl of this.session.startUrls) {
try {
const url = new URL(startUrl);
const sitemapUrl = `${url.protocol}//${url.host}/sitemap.xml`;
const sitemap = await this.fetchSitemap(sitemapUrl);
if (sitemap) {
for (const sitemapUrl of sitemap.urls) {
if (this.shouldAddUrl(sitemapUrl.url, {
url: startUrl,
depth: -1,
source: 'initial',
priority: 100,
discoveredAt: new Date(),
attempts: 0,
status: 'pending'
})) {
const urlEntry: URLEntry = {
url: sitemapUrl.url,
depth: 0,
source: 'sitemap',
priority: Math.round((sitemapUrl.priority || 0.5) * 100),
discoveredAt: new Date(),
attempts: 0,
status: 'pending',
};
this.session.urls.set(sitemapUrl.url, urlEntry);
this.requestQueue.push(urlEntry);
this.emitEvent('url_discovered', { url: sitemapUrl.url });
}
}
}
} catch (error) {
console.warn(`Failed to fetch sitemap for ${startUrl}:`, error);
}
}
}
/**
* Fetch and parse sitemap
* @private
*/
private async fetchSitemap(sitemapUrl: string): Promise<SitemapInfo | null> {
// Placeholder implementation
// In a real implementation, this would fetch and parse XML sitemap
return null;
}
/**
* Apply rate limiting
* @private
*/
private async applyRateLimit(): Promise<void> {
if (!this.session) return;
const { requestDelay } = this.session.config;
const timeSinceLastRequest = Date.now() - this.lastRequestTime;
if (timeSinceLastRequest < requestDelay) {
const delayNeeded = requestDelay - timeSinceLastRequest;
this.emitEvent('rate_limit_applied', { delay: delayNeeded });
await this.sleep(delayNeeded);
}
this.lastRequestTime = Date.now();
}
/**
* Get next URL to process
* @private
*/
private getNextUrl(): URLEntry | null {
// Sort by priority (higher first) and then by discovery time
this.requestQueue.sort((a, b) => {
if (a.priority !== b.priority) {
return b.priority - a.priority;
}
return a.discoveredAt.getTime() - b.discoveredAt.getTime();
});
return this.requestQueue.shift() || null;
}
/**
* Check if there's more work to do
* @private
*/
private hasWorkToDo(): boolean {
return this.requestQueue.length > 0 || this.activeRequests.size > 0;
}
/**
* Wait for all active requests to complete
* @private
*/
private async waitForActiveRequests(): Promise<void> {
while (this.activeRequests.size > 0) {
await this.sleep(100);
}
}
/**
* Validate start URLs
* @private
*/
private validateStartUrls(urls: string[]): string[] {
const validUrls: string[] = [];
for (const url of urls) {
try {
const urlObj = new URL(url);
if (['http:', 'https:'].includes(urlObj.protocol)) {
validUrls.push(url);
}
} catch (error) {
console.warn(`Invalid start URL: ${url}`);
}
}
return validUrls;
}
/**
* Create default crawler configuration
* @private
*/
private createDefaultConfig(config: Partial<CrawlerConfig>): CrawlerConfig {
return {
maxDepth: 2,
maxPages: 100,
allowedDomains: [],
excludedDomains: [],
excludedPaths: [],
includedPaths: [],
requestDelay: 1000,
maxConcurrency: 5,
respectRobotsTxt: true,
discoverExternalLinks: false,
useSitemaps: true,
userAgent: 'a11yanalyze/1.0.0 (Accessibility Scanner)',
requestTimeout: 30000,
...config,
};
}
/**
* Create initial statistics
* @private
*/
private createInitialStats(): CrawlStats {
return {
urlCounts: {
total: 0,
pending: 0,
processing: 0,
completed: 0,
failed: 0,
skipped: 0,
excluded: 0,
},
pagesScanned: 0,
totalIssues: 0,
issuesBySeverity: {
critical: 0,
serious: 0,
moderate: 0,
minor: 0,
warning: 0,
},
averageScore: 0,
performance: {
avgPageLoadTime: 0,
avgScanTime: 0,
totalCrawlTime: 0,
pagesPerMinute: 0,
},
wcagCompliance: {
compliantPages: 0,
nonCompliantPages: 0,
complianceRate: 0,
levelBreakdown: {
A: 0,
AA: 0,
AAA: 0,
ARIA: 0,
},
},
};
}
/**
* Update statistics
* @private
*/
private updateStats(): void {
if (!this.session) return;
const urls = Array.from(this.session.urls.values());
const stats = this.session.stats;
// Update URL counts
stats.urlCounts.total = urls.length;
stats.urlCounts.pending = urls.filter(u => u.status === 'pending').length;
stats.urlCounts.processing = urls.filter(u => u.status === 'processing').length;
stats.urlCounts.completed = urls.filter(u => u.status === 'completed').length;
stats.urlCounts.failed = urls.filter(u => u.status === 'failed').length;
stats.urlCounts.skipped = urls.filter(u => u.status === 'skipped').length;
stats.urlCounts.excluded = urls.filter(u => u.status === 'excluded').length;
// Update performance metrics
const elapsed = Date.now() - this.session.startTime.getTime();
stats.performance.totalCrawlTime = elapsed;
stats.performance.pagesPerMinute = elapsed > 0 ? (stats.pagesScanned / (elapsed / 60000)) : 0;
}
/**
* Update statistics for completed URL
* @private
*/
private updateStatsForCompletedUrl(scanResult: ScanResult): void {
if (!this.session) return;
const stats = this.session.stats;
stats.pagesScanned++;
stats.totalIssues += scanResult.issues.length;
// Update issues by severity
for (const issue of scanResult.issues) {
stats.issuesBySeverity[issue.severity]++;
}
// Update average score
const totalScore = stats.averageScore * (stats.pagesScanned - 1) + scanResult.score;
stats.averageScore = totalScore / stats.pagesScanned;
// Update WCAG compliance
if (scanResult.compliance?.compliant) {
stats.wcagCompliance.compliantPages++;
} else {
stats.wcagCompliance.nonCompliantPages++;
}
stats.wcagCompliance.complianceRate =
stats.wcagCompliance.compliantPages / stats.pagesScanned;
// Update level breakdown
if (scanResult.compliance?.levelBreakdown) {
for (const [level, count] of Object.entries(scanResult.compliance.levelBreakdown)) {
if (level in stats.wcagCompliance.levelBreakdown) {
(stats.wcagCompliance.levelBreakdown as any)[level] += count;
}
}
}
}
/**
* Handle crawl errors
* @private
*/
private handleCrawlError(error: any): void {
if (this.session) {
this.session.status = 'failed';
this.session.error = error instanceof Error ? error.message : String(error);
this.session.endTime = new Date();
this.emitEvent('session_failed', { error: this.session.error });
}
}
/**
* Emit crawl event
* @private
*/
private emitEvent(type: CrawlEventType, data: any = {}): void {
const event: CrawlEvent = {
type,
timestamp: new Date(),
sessionId: this.session?.id || '',
...data,
};
this.emit('crawl_event', event);
this.emit(type, event);
}
/**
* Get current crawl depth
* @private
*/
private getCurrentDepth(): number {
if (!this.session) return 0;
const urls = Array.from(this.session.urls.values());
const processingUrls = urls.filter(u => u.status === 'processing');
return processingUrls.length > 0
? Math.max(...processingUrls.map(u => u.depth))
: Math.max(...urls.map(u => u.depth));
}
/**
* Get current URL being processed
* @private
*/
private getCurrentUrl(): string | undefined {
if (!this.session) return undefined;
const urls = Array.from(this.session.urls.values());
const processingUrl = urls.find(u => u.status === 'processing');
return processingUrl?.url;
}
/**
* Get recent errors
* @private
*/
private getRecentErrors(): string[] {
if (!this.session) return [];
// Prefer session.recentErrors if available
if ('recentErrors' in this.session && Array.isArray((this.session as any).recentErrors)) {
return (this.session as any).recentErrors.slice(0, 10);
}
// Fallback to previous logic
const urls = Array.from(this.session.urls.values());
const failedUrls = urls
.filter(u => u.status === 'failed' && u.error)
.sort((a, b) => (b.lastAttempt?.getTime() || 0) - (a.lastAttempt?.getTime() || 0))
.slice(0, 10);
return failedUrls.map(u => u.error!);
}
/**
* Generate session ID
* @private
*/
private generateSessionId(): string {
return `crawl_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Sleep for specified duration
* @private
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Cleanup resources
* @private
*/
private async cleanup(): Promise<void> {
this.isRunning = false;
this.isPaused = false;
this.abortController = null;
this.activeRequests.clear();
this.requestQueue = [];
await this.pageScanner.cleanup();
}
}