===== RENDERER CONFIGURATION ===== // Accessibility check for the full audit, drawn the way a browser draws it. // // The Accessibility page tool (public/accessibility-audit.html) runs its checks on the page as Chrome // draws it with the site's own CSS: it measures colour contrast and control edges, and it knows which // text the CSS hides. lib/accessibility-checker.js reads the raw HTML only. This module runs the page // tool's own check (checkA11y) in one shared headless Chrome on the HTML the full audit already fetched, // so /api/audit-full gives customers the same answers as the page tool. Any failure (Chrome missing, // time-out, crash) rejects, and the caller falls back to lib/accessibility-checker.js. // // Every page is first loaded with its own scripts running, in Chrome's sandbox, and a copy of the finished // page is audited (renderWithScripts), so the audit sees what visitors see after the page loads (content, // colours and banners added by scripts, and pages built entirely by JavaScript). A bot-check page is never // run with scripts: a site that blocks checkers is respected, not worked around. If the page cannot be loaded // that way (error status, bot-check, or it comes back almost empty), the fetched HTML is audited instead. // // Safety: Chrome runs with its sandbox ON (it runs other sites' scripts). Every request Chrome makes (stylesheets go through the app's own guarded fetch; images and // fonts the audited page asks for) is checked first and blocked if it points at a private, loopback or // link-local address, so an audited page cannot make the server call itself or its network. The tool // page's own analytics are blocked so server-side runs are not counted as visits. 'use strict'; const path = require('path'); const fs = require('fs'); const dns = require('dns').promises; const net = require('net'); const puppeteer = require('puppeteer'); const TOOL = 'file://' + path.join(__dirname, '..', 'public', 'accessibility-audit.html'); // The tool page keeps its checker and styles in its own files; they are added to the page from this server's copy // after it opens (the page's own /accessibility-audit.js and .css links do not resolve from a file:// address). const TOOL_JS = path.join(__dirname, '..', 'public', 'accessibility-audit.js'); const TOOL_CSS = path.join(__dirname, '..', 'public', 'accessibility-audit.css'); const MAX_PAGES = 3; // pages drawn at once; more wait their turn const TIMEOUT_MS = 60000; // whole check (contrast + the axe-core checks), then the caller falls back to the raw-HTML check // axe-core and its runner, asked for by the tool page's check frame at this address, are served from this // server's own copy (public/a11y-axe/), so the full audit does not depend on fetching them over the network. const AXE_URL = 'https://aiwebpageseo.com/a11y-axe/'; const AXE_FILES = { 'axe.min.js': path.join(__dirname, '..', 'public', 'a11y-axe', 'axe.min.js'), 'run.js': path.join(__dirname, '..', 'public', 'a11y-axe', 'run.js') }; const BLOCK_HOSTS = /(^|\.)(googletagmanager\.com|google-analytics\.com|analytics\.google\.com|doubleclick\.net|clarity\.ms|facebook\.net|hotjar\.com)$/i; let browserP = null; function getBrowser() { if (!browserP) { browserP = puppeteer.launch({ headless: 'new', executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, args: ['--disable-dev-shm-usage'] // sandbox on: other sites' scripts may run in it }).then(b => { b.on('disconnected', () => { browserP = null; }); return b; }) .catch(e => { browserP = null; throw e; }); } return browserP; } // A keyboard check photographs the page as focus moves, and Chrome only draws the front tab, so each keyboard check // gets a Chrome of its own (closed when it ends) and up to KB_MAX run at once; more wait their turn. const KB_MAX = 5; let kbActive = 0; const kbWaiting = []; function kbAcquire() { if (kbActive < KB_MAX) { kbActive++; return Promise.resolve(); } return new Promise(r => kbWaiting.push(r)); } function kbRelease() { const next = kbWaiting.shift(); if (next) next(); else kbActive--; } function ownBrowser() { return puppeteer.launch({ headless: 'new', executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, args: ['--disable-dev-shm-usage'] }); } // pages drawn at once let active = 0; const waiting = []; function acquire() { if (active < MAX_PAGES) { active++; return Promise.resolve(); } return new Promise(r => waiting.push(r)); } function release() { const next = waiting.shift(); if (next) next(); else active--; } // private / loopback / link-local / shared / multicast / unspecified addresses function privateIp(ip) { if (net.isIPv4(ip)) { const [a, b] = ip.split('.').map(Number); return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224; } const v = ip.toLowerCase(); if (v === '::' || v === '::1') return true; const m = v.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (m) return privateIp(m[1]); return /^(fe[89ab]|f[cd]|ff)/.test(v); } const hostCache = new Map(); async function publicHost(host) { host = host.replace(/^\[|\]$/g, '').toLowerCase(); if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) return false; if (net.isIP(host)) return !privateIp(host); if (hostCache.has(host)) return hostCache.get(host); let ok = false; try { const addrs = await dns.lookup(host, { all: true }); ok = addrs.length > 0 && addrs.every(a => !privateIp(a.address)); } catch (e) { ok = false; } hostCache.set(host, ok); if (hostCache.size > 2000) hostCache.clear(); return ok; } // Same test as the page tool: almost no words and at most one link = the HTML is not the page visitors see. // Bot-check pages (same rule as the page tool). Clear signs always mean a bot-check. Protection and form-captcha // script names also appear on ordinary full pages, so alone they mean a bot-check only on a tiny page. const BOT_SURE = /cf-chl-(?!widget)|cf_chl_opt|px-captcha|Pardon Our Interruption|Attention Required!? \| Cloudflare|]*>\s*(Just a moment|Access Denied|Attention Required|Request (unsuccessful|rejected)|Are you a robot|Checking your browser|Making sure you|Vercel Security Checkpoint|Just a quick security check|Access to this page has been denied|Sorry, you have been blocked|Your access has been blocked)/i; const BOT_MAYBE = /challenge-platform|cf-turnstile|cf-chl-widget|_Incapsula_Resource|perimeterx|g-recaptcha|h-captcha|datadome/i; function pageSize(html) { const body = String(html || '').replace(/<(script|style|noscript|template)\b[\s\S]*?<\/\1\s*>/gi, ' '); const words = body.replace(/<[^>]*>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').trim().split(/\s+/).filter(Boolean).length; const links = (body.match(/]*\bhref\s*=/gi) || []).length; return { words: words, links: links }; } function nearlyEmpty(html) { const z = pageSize(html); return z.words < 40 && z.links <= 1; } function botCheck(html) { const z = pageSize(html); return BOT_SURE.test(html) || (BOT_MAYBE.test(html) && z.words < 300 && z.links <= 5); } // Load a page with its own scripts running (sandboxed) and return a copy of the finished page: // { ok, status, html, finalUrl }. Styles that scripts put straight into the browser (CSS-in-JS) are // written into the copy, and scripts are taken out, so the copy draws the same with scripts off. // Keyboard check (WCAG 2.4.7 Focus Visible, 2.4.11 Focus Not Obscured, 2.1.2 No Keyboard Trap), on the live page // after its copy has been taken: the page is moved through with the Tab key only, as a keyboard user does, for up to // KB_STOPS tab stops. A cookie / consent pop-up holding focus is answered first (as a visitor answers it), so the page // itself is reached. At each stop: the window is photographed with the control focused and again with focus taken // off it; no pixel changing in and around the control means nothing shows where focus is. A control whose every // point is covered by a fixed or sticky box (sticky header, cookie bar) and shows no change is hidden behind it. // Focus on something with no size, or mostly outside the window after the browser scrolled to it, cannot be seen. // Text fields are left out of "no focus shown" (the text cursor shows where focus is). Returns null on failure. const KB_STOPS = 40, KB_TIME_MS = 30000; const KB_CONSENT = '#onetrust-accept-btn-handler, #onetrust-reject-all-handler, #CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll, #CybotCookiebotDialogBodyButtonDecline, .fc-cta-consent, .fc-button.fc-cta-do-not-consent, #truste-consent-button, .osano-cm-accept-all, .osano-cm-denyAll, #didomi-notice-agree-button, [data-testid="uc-accept-all-button"], .cky-btn-accept, #hs-eu-confirmation-button, .cm-btn-success, #ccc-recommended-settings, #ccc-notify-accept, .qc-cmp2-summary-buttons button, button[mode="primary"]'; const KB_WORDS = /^\s*(agree (and|&) proceed|accept (and|&) continue|i understand|allow|accept all( cookies)?|accept( cookies)?|allow all( cookies)?|allow cookies|agree( and close)?|i agree|i accept|yes,? i('|’)m happy|got it|ok(ay)?|continue|reject all|decline( all)?|only necessary|necessary only|essential only)\s*$/i; // One keyboard check at a time: the page checked must stay the front tab while it is photographed. let kbChain = Promise.resolve(); function withKbLock(fn) { const run = kbChain.then(fn, fn); kbChain = run.catch(() => {}); return run; } const kbWithin = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('keyboard check step timed out')), ms))]); // Layout checks on the live page (before the keyboard check): // - text spacing (WCAG 1.4.12): the spacing WCAG sets (line height 1.5, letter spacing 0.12em, word spacing 0.16em, // paragraph spacing 2em) is applied; a block that hides its overflow and did not cut its text off before, but does // with that spacing, loses text. Blocks already cut off before (closed menus, truncated teasers) are left out. // - reflow (WCAG 1.4.10): at 320px wide (a phone, or a desktop browser zoomed to 400%) the page must not need // sideways scrolling. The outermost boxes wider than the screen are named; tables, preformatted text, video, // maps, frames and drawings (2D content WCAG exempts) and what is inside scrolling boxes are left out. // Content shown on hover or focus (WCAG 1.4.13): a pop-up that appears when the pointer rests on something (a menu, a // tooltip) must close with Escape without moving the pointer, and must stay while the pointer moves onto it. Up to 6 // menu items and tooltip owners near the top of the page are tried; only new visible content with text counts. async function hoverCheck(page) { const ids = await kbWithin(page.evaluate(() => { const vis = e => { const r = e.getBoundingClientRect(), st = getComputedStyle(e); return r.width > 0 && r.height > 0 && st.visibility === 'visible' && st.display !== 'none' && parseFloat(st.opacity) > 0.1; }; ===== BROWSER MANAGEMENT ===== 25:const puppeteer = require('puppeteer'); 27:const TOOL = 'file://' + path.join(__dirname, '..', 'public', 'accessibility-audit.html'); 30:const TOOL_JS = path.join(__dirname, '..', 'public', 'accessibility-audit.js'); 31:const TOOL_CSS = path.join(__dirname, '..', 'public', 'accessibility-audit.css'); 33:const TIMEOUT_MS = 60000; // whole check (contrast + the axe-core checks), then the caller falls back to the raw-HTML check 36:const AXE_URL = 'https://aiwebpageseo.com/a11y-axe/'; 37:const AXE_FILES = { 'axe.min.js': path.join(__dirname, '..', 'public', 'a11y-axe', 'axe.min.js'), 'run.js': path.join(__dirname, '..', 'public', 'a11y-axe', 'run.js') }; 41:function getBrowser() { 43: browserP = puppeteer.launch({ 59:function ownBrowser() { return puppeteer.launch({ headless: 'new', executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, args: ['--disable-dev-shm-usage'] }); } 64:function acquire() { if (active < MAX_PAGES) { active++; return Promise.resolve(); } return new Promise(r => waiting.push(r)); } 65:function release() { const next = waiting.shift(); if (next) next(); else active--; } 795: const report = await Promise.race([run, new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('rendered accessibility check timed out')), TIMEOUT_MS); })]); ===== FINAL REPORT FIELDS ===== const hit = new Map(); for (const i of issues) for (const c of scs(i)) { const h = hit.get(c) || { e: 0, w: 0 }; if (i.type === 'error') h.e++; else h.w++; hit.set(c, h); } return L.map(([sc, level, name, how, ran, person]) => { const h = hit.get(sc); const status = h && h.e ? 'fail' : h && h.w ? 'warn' : !how ? 'person' : !ran ? 'notrun' : how === 'part' ? 'review' : 'pass'; return { sc, level, name, status, partly: how === 'part', person }; }); })(); // Score const errorCount = issues.filter(i => i.type === 'error').length; const warnCount = issues.filter(i => i.type === 'warning').length; const score = Math.max(0, 100 - (errorCount * 15) - (warnCount * 5)); // The finished report as data, for the server's full audit (lib/a11y-rendered.js), which runs this // same check in headless Chrome so customers get the page tool's answers there too. window.a11yLastReport = { issues: issues.map(i => Object.assign({}, i)), passes: passes.slice(), score: score, errorCount: errorCount, warnCount: warnCount, passCount: passes.length, notes: (typeof notes !== 'undefined' ? notes.slice() : []), coverage: a11yCoverage, contrast: contrast ? contrast.status : null }; const scoreCol = score >= 80 ? '#22c55e' : score >= 50 ? '#c2410c' : '#ef4444'; const ringCls = score >= 80 ? 'ring-good' : score >= 50 ? 'ring-warn' : 'ring-bad'; let html2 = `
Accessibility Audit Results
${score}
Score / 100
${errorCount}
Errors
${warnCount}
Warnings
===== TEST DIRECTORY ===== drwxr-xr-x 3 root root 4096 Oct 22 16:11 /var/www/vhosts/aiwebpageseo.com/site-audit/public/tests/act-w3c ===== PRODUCTION SOURCE HASHES ===== 305032a77f4ca61e8fd7b4d7aa4b1acab320038900d9393a2b99c5a0be77725b /var/www/vhosts/aiwebpageseo.com/site-audit/lib/a11y-rendered.js 88422ce140ee30ffa1c972ff306e2595a7dff6408be0b64cf9567148b4230b60 /var/www/vhosts/aiwebpageseo.com/site-audit/public/accessibility-audit.js bb62d2c9e747507641b676c5faf26b9794905acb39129d736356c4b5501d9958 /var/www/vhosts/aiwebpageseo.com/site-audit/public/a11y-axe/run.js a63abfcdc28fab7516d895933e0f2457411d0d922d9eb942a06d2e71fad039f9 /var/www/vhosts/aiwebpageseo.com/site-audit/public/a11y-axe/text-spacing.js