===== 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
===== 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