From 075c9637b0af10cb0020c334d879fe59e58e9ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:03:43 +0300 Subject: [PATCH 001/114] Add Russian and English language switching --- public/i18n.js | 165 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 public/i18n.js diff --git a/public/i18n.js b/public/i18n.js new file mode 100644 index 0000000..ee0c61e --- /dev/null +++ b/public/i18n.js @@ -0,0 +1,165 @@ +(() => { + "use strict"; + const KEY = "webcheck-language"; + const translations = { + "Сайт в порядке?":"Is your website in shape?", + "Проверим.":"Let's find out.", + "Введите адрес — WebCheck соберёт базовые SEO, security, social и technical сигналы":"Enter a URL — WebCheck checks SEO, security, social and technical signals", + "и превратит их в короткий понятный отчёт.":"and turns them into a clear, actionable report.", + "Адрес сайта":"Website address", + "START SCAN":"START SCAN", + "NO ACCOUNT":"NO ACCOUNT", + "PUBLIC SITES ONLY":"PUBLIC SITES ONLY", + "WHAT WE CHECK":"WHAT WE CHECK", + "Что проверяем":"What we check", + "Title, description, canonical, H1, language, viewport и индексация.":"Title, description, canonical, H1, language, viewport and indexing.", + "HTTPS, HSTS, CSP и основные security headers.":"HTTPS, HSTS, CSP and key security headers.", + "OpenGraph и готовность страницы к нормальному preview ссылок.":"OpenGraph and link preview readiness.", + "HTTP status, redirects, HTML size, favicon и время ответа.":"HTTP status, redirects, HTML size, favicon and response time.", + "SCAN IN PROGRESS":"SCAN IN PROGRESS", + "Подключение к сайту":"Connecting to website", + "Чтение страницы":"Reading page", + "Проверка сигналов":"Inspecting signals", + "Сбор результатов":"Building report", + "Что найдено":"Findings", + "Что исправить":"Recommended actions", + "Показать детали":"Show details", + "← NEW WEBSITE":"← NEW WEBSITE", + "COPY REPORT":"COPY REPORT", + "RESCAN":"RESCAN", + "Отличное техническое состояние. Основные проверки пройдены.":"Excellent technical health. Core checks passed.", + "Хорошая база. Остались несколько точечных улучшений.":"Good foundation. A few improvements remain.", + "Рабочее состояние, но часть важных сигналов отсутствует.":"Operational, but some important signals are missing.", + "Есть заметные SEO или security пробелы, которые стоит исправить.":"Notable SEO or security gaps need attention.", + "Нужна базовая техническая доработка сайта.":"The website needs foundational technical improvements.", + "Основных замечаний нет":"No major issues found", + "Базовые проверки WebCheck пройдены успешно.":"The WebCheck baseline checks passed.", + "Отчёт скопирован":"Report copied", + "Введите адрес сайта":"Enter a website address", + "Не удалось проверить сайт":"Could not check the website", + "Ошибка проверки":"Scan failed", + "Браузер не разрешил копирование":"The browser blocked clipboard access", + "Не найден":"Not found", + "Не найдена":"Not found", + "Не найдено":"Not found", + "Не указан":"Not specified", + "Не указана":"Not specified", + "Не указано":"Not specified", + "Доступен":"Available", + "Найдена":"Found", + "Настроен":"Configured", + "Не настроен":"Not configured", + "Не найдено":"Not found", + "Найдено несколько H1. Убедитесь, что структура заголовков логична.":"Multiple H1 headings found. Check that the heading structure is logical.", + "На странице не найден основной заголовок H1.":"No primary H1 heading was found on the page.", + "CSP ограничивает нежелательные источники контента.":"CSP restricts unwanted content sources.", + "HSTS заставляет браузер использовать HTTPS.":"HSTS tells browsers to use HTTPS.", + "nosniff запрещает MIME-sniffing.":"nosniff prevents MIME sniffing.", + "Политика referrer ограничивает передачу адреса источника.":"Referrer policy limits how much source URL information is shared.", + "Title объясняет содержание страницы пользователю и поисковику.":"The title explains the page to visitors and search engines.", + "Описание помогает формировать понятный поисковый сниппет.":"The description helps create a useful search snippet.", + "Viewport нужен для корректной мобильной адаптации.":"Viewport is needed for proper mobile rendering.", + "Canonical помогает избежать дублей URL.":"Canonical helps prevent duplicate URL issues.", + "robots.txt задаёт правила для поисковых роботов.":"robots.txt provides rules for search crawlers.", + "Sitemap помогает поисковым системам находить страницы.":"A sitemap helps search engines discover pages.", + "Для основной страницы желательно иметь понятный основной H1.":"A clear primary H1 is recommended for the main page.", + "Язык документа полезен браузерам, поиску и accessibility.":"The document language helps browsers, search and accessibility tools.", + "Явная кодировка снижает риск проблем с текстом.":"An explicit character encoding reduces text rendering issues.", + "Заголовок OpenGraph используется в preview ссылок.":"The OpenGraph title is used in link previews.", + "Описание делает preview ссылки понятнее.":"The description makes link previews clearer.", + "Картинка делает ссылку заметнее в соцсетях и мессенджерах.":"An image makes links stand out in social apps and messengers.", + "CSP помогает снизить последствия некоторых XSS-инъекций.":"CSP can reduce the impact of some XSS vulnerabilities.", + "Первый ответ занимает больше 2.5 секунд. Проверьте backend, хостинг и кэширование.":"The first response takes over 2.5 seconds. Check the backend, hosting and caching.", + "Переведите сайт на HTTPS":"Move the website to HTTPS", + "Исправьте HTTP-ответ":"Fix the HTTP response", + "Добавьте title":"Add a title tag", + "Добавьте meta description":"Add a meta description", + "Добавьте meta viewport":"Add a meta viewport", + "Укажите canonical URL":"Set a canonical URL", + "Добавьте robots.txt":"Add robots.txt", + "Добавьте sitemap.xml":"Add sitemap.xml", + "Добавьте H1":"Add an H1 heading", + "Проверьте структуру H1":"Review the H1 structure", + "Заполните OpenGraph":"Complete OpenGraph metadata", + "Настройте Content-Security-Policy":"Configure Content-Security-Policy", + "Включите HSTS":"Enable HSTS", + "Добавьте X-Content-Type-Options":"Add X-Content-Type-Options", + "Ускорьте серверный ответ":"Improve server response time", + "OK":"PASS", + "CHECK":"REVIEW", + "FAIL":"FAIL", + "IMPORTANT":"HIGH PRIORITY", + "IMPROVE":"IMPROVE", + "Отчёт":"Report", + "Проверка":"Scan", + "Не найден":"Not found", + "Не указана":"Not specified" + }; + + let language = localStorage.getItem(KEY) === "en" ? "en" : "ru"; + const originalText = new WeakMap(); + + function translateNode(node) { + if (node.nodeType === Node.TEXT_NODE) { + if (!originalText.has(node)) originalText.set(node, node.nodeValue); + const source = originalText.get(node); + const trimmed = source.trim(); + if (!trimmed) return; + const translated = language === "en" ? (translations[trimmed] || trimmed) : trimmed; + const leading = source.match(/^\s*/)?.[0] || ""; + const trailing = source.match(/\s*$/)?.[0] || ""; + node.nodeValue = leading + translated + trailing; + return; + } + if (node.nodeType === Node.ELEMENT_NODE) { + if (node.matches("script,style")) return; + for (const child of node.childNodes) translateNode(child); + } + } + + function translateAttributes() { + const input = document.querySelector("#urlInput"); + if (input) input.placeholder = language === "en" ? "example.com" : "cayplay.ru"; + document.documentElement.lang = language; + document.title = language === "en" ? "WebCheck — Website Diagnostics" : "WebCheck — Диагностика сайтов"; + } + + function applyLanguage() { + translateNode(document.body); + translateAttributes(); + const button = document.querySelector("#languageToggle"); + if (button) { + button.textContent = language === "en" ? "RU" : "EN"; + button.setAttribute("aria-label", language === "en" ? "Switch to Russian" : "Switch to English"); + button.title = button.getAttribute("aria-label"); + } + } + + function mountToggle() { + const header = document.querySelector(".header-right"); + if (!header || document.querySelector("#languageToggle")) return; + const button = document.createElement("button"); + button.id = "languageToggle"; + button.type = "button"; + button.className = "language-toggle"; + button.addEventListener("click", () => { + language = language === "ru" ? "en" : "ru"; + localStorage.setItem(KEY, language); + applyLanguage(); + }); + header.append(button); + applyLanguage(); + } + + const observer = new MutationObserver(records => { + for (const record of records) { + if (record.type === "childList") record.addedNodes.forEach(translateNode); + else if (record.type === "characterData") translateNode(record.target); + } + }); + + document.addEventListener("DOMContentLoaded", () => { + mountToggle(); + observer.observe(document.body, {subtree:true, childList:true, characterData:true}); + }); +})(); \ No newline at end of file From 82cbe585afbbaea1183ab78627faa6431fd9179a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:04:06 +0300 Subject: [PATCH 002/114] Style the language switcher --- public/styles.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/public/styles.css b/public/styles.css index 895e70d..c53cc69 100644 --- a/public/styles.css +++ b/public/styles.css @@ -705,4 +705,8 @@ main{ } @media(prefers-reduced-motion:reduce){ *,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important} -} \ No newline at end of file +} + +.language-toggle{min-width:42px;height:34px;padding:0 10px;border:1px solid var(--line);background:transparent;color:var(--ink);font-size:10px;font-weight:900;letter-spacing:.08em;cursor:pointer;transition:background .15s ease,color .15s ease,border-color .15s ease} +.language-toggle:hover{background:var(--ink);color:white;border-color:var(--ink)} +@media(max-width:720px){.header-right{gap:10px}.version{display:none}.language-toggle{min-width:38px;height:32px}} From ead94a7aa7accf2e149293fc494df45647bbf410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:04:09 +0300 Subject: [PATCH 003/114] Load bilingual interface translations --- public/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/public/index.html b/public/index.html index b494c39..80a821f 100644 --- a/public/index.html +++ b/public/index.html @@ -227,6 +227,7 @@

example.com

Done
+ \ No newline at end of file From 6a4eb7d77cfea7f4645da06c809e231a2f361cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:04:36 +0300 Subject: [PATCH 004/114] Prevent translation observer feedback loops --- public/i18n.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/i18n.js b/public/i18n.js index ee0c61e..7ddb9c4 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -108,7 +108,8 @@ const translated = language === "en" ? (translations[trimmed] || trimmed) : trimmed; const leading = source.match(/^\s*/)?.[0] || ""; const trailing = source.match(/\s*$/)?.[0] || ""; - node.nodeValue = leading + translated + trailing; + const output = leading + translated + trailing; + if (node.nodeValue !== output) node.nodeValue = output; return; } if (node.nodeType === Node.ELEMENT_NODE) { From 4a257e959b0cdf030e01c2455a45fc8d0f4609d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:05:47 +0300 Subject: [PATCH 005/114] Add passive security audit checks --- security-audit.js | 138 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 security-audit.js diff --git a/security-audit.js b/security-audit.js new file mode 100644 index 0000000..74e5c4b --- /dev/null +++ b/security-audit.js @@ -0,0 +1,138 @@ +"use strict"; + +function check(key, label, description, status, value, weight=3){ + return {category:"security", key, label, description, status, value, weight}; +} + +function runSecurityAudit({html="", headers={}, protocol="https:"}={}){ + const source = String(html); + const csp = String(headers["content-security-policy"] || ""); + const cookies = headers["set-cookie"] || []; + const cookieLines = Array.isArray(cookies) ? cookies : [cookies]; + const lowerHtml = source.toLowerCase(); + const secure = protocol === "https:"; + const checks = []; + const recommendations = []; + const add = (condition, title, text, priority="medium") => { + if(condition) recommendations.push({title,text,priority}); + }; + + const frameAncestors = /(?:^|;)\s*frame-ancestors\s+[^;]+/i.test(csp); + const xFrame = Boolean(headers["x-frame-options"]); + checks.push(check( + "frameProtection","Clickjacking protection", + "X-Frame-Options or CSP frame-ancestors helps control whether other sites can embed this page.", + xFrame || frameAncestors ? "pass" : "warn", + xFrame ? String(headers["x-frame-options"]) : frameAncestors ? "CSP frame-ancestors configured" : "Not configured",4 + )); + add(!xFrame && !frameAncestors,"Add anti-framing protection", + "Configure CSP frame-ancestors or X-Frame-Options to reduce clickjacking risk."); + + const permissions = String(headers["permissions-policy"] || ""); + checks.push(check("permissionsPolicy","Permissions-Policy", + "Controls access to browser features such as camera, microphone and geolocation.", + permissions ? "pass" : "warn",permissions || "Not configured",3)); + add(!permissions,"Review Permissions-Policy", + "Set an explicit Permissions-Policy that disables browser features the site does not need."); + + for (const [key, header, label, description] of [ + ["coop","cross-origin-opener-policy","Cross-Origin-Opener-Policy","Helps isolate the browsing context from cross-origin windows."], + ["coep","cross-origin-embedder-policy","Cross-Origin-Embedder-Policy","Controls which cross-origin resources may be embedded."], + ["corp","cross-origin-resource-policy","Cross-Origin-Resource-Policy","Controls which origins may load this resource."] + ]){ + const value = String(headers[header] || ""); + checks.push(check(key,label,description,value ? "pass" : "warn",value || "Not configured",2)); + } + + const cookieCount = cookieLines.filter(Boolean).length; + let cookiesWithoutSecure = 0; + let cookiesWithoutHttpOnly = 0; + let cookiesWithoutSameSite = 0; + for(const line of cookieLines){ + const parts = String(line).split(";").slice(1).map(part => part.trim().toLowerCase()); + if(secure && !parts.includes("secure")) cookiesWithoutSecure++; + if(!parts.includes("httponly")) cookiesWithoutHttpOnly++; + if(!parts.some(part => part.startsWith("samesite="))) cookiesWithoutSameSite++; + } + if(cookieCount){ + checks.push(check("cookieSecure","Cookie Secure flag", + "Session cookies sent over HTTPS should use the Secure attribute.", + secure && cookiesWithoutSecure === 0 ? "pass" : secure ? "warn" : "info", + secure ? `${cookieCount - cookiesWithoutSecure}/${cookieCount} cookies marked Secure` : "Site uses HTTP",4)); + checks.push(check("cookieHttpOnly","Cookie HttpOnly flag", + "HttpOnly prevents JavaScript from reading cookies that do not need client-side access.", + cookiesWithoutHttpOnly === 0 ? "pass" : "warn", + `${cookieCount - cookiesWithoutHttpOnly}/${cookieCount} cookies marked HttpOnly`,3)); + checks.push(check("cookieSameSite","Cookie SameSite attribute", + "SameSite helps control when browsers attach cookies to cross-site requests.", + cookiesWithoutSameSite === 0 ? "pass" : "warn", + `${cookieCount - cookiesWithoutSameSite}/${cookieCount} cookies specify SameSite`,3)); + add(secure && cookiesWithoutSecure > 0,"Review cookie Secure flags", + `${cookiesWithoutSecure} cookie(s) were missing Secure on an HTTPS site.`); + add(cookiesWithoutHttpOnly > 0,"Review cookie HttpOnly flags", + `${cookiesWithoutHttpOnly} cookie(s) were missing HttpOnly. Only add it where client-side access is not required.`); + add(cookiesWithoutSameSite > 0,"Set explicit SameSite cookie policies", + `${cookiesWithoutSameSite} cookie(s) did not declare SameSite.`); + }else{ + checks.push(check("cookies","Set-Cookie headers", + "Checks cookie attributes when the server sets cookies.", + "info","No Set-Cookie header on this response",1)); + } + + const mixedResources = (source.match(/(?:src|href|poster)\s*=\s*["']http:\/\/[^"']+["']/gi) || []).length; + checks.push(check("mixedContent","Mixed content", + "HTTPS pages should avoid loading HTTP resources that browsers may block or downgrade.", + mixedResources === 0 ? "pass" : "warn", + mixedResources === 0 ? "No HTTP resource URLs found in markup" : `${mixedResources} HTTP resource URL(s) found`,5)); + add(mixedResources > 0,"Remove mixed content", + `Found ${mixedResources} HTTP resource URL(s) in the page markup. Serve them over HTTPS.`,"high"); + + const insecureForms = [...source.matchAll(/]*>/gi)].filter(match => { + const action = (match[0].match(/\baction\s*=\s*["']([^"']*)["']/i) || [,""])[1]; + return /^http:\/\//i.test(action); + }).length; + checks.push(check("insecureForms","Insecure form actions", + "Forms should not submit sensitive data to an unencrypted HTTP endpoint.", + insecureForms === 0 ? "pass" : "fail", + insecureForms === 0 ? "No explicit HTTP form actions found" : `${insecureForms} HTTP form action(s) found`,6)); + add(insecureForms > 0,"Fix insecure form destinations", + `Found ${insecureForms} form(s) submitting to HTTP. Use HTTPS destinations.`,"high"); + + const passwordFields = (source.match(/]*type\s*=\s*["']password["'][^>]*>/gi) || []).length; + checks.push(check("passwordFormTransport","Password form transport", + "Password fields on a page served over HTTP expose credentials in transit.", + passwordFields === 0 || secure ? "pass" : "fail", + passwordFields === 0 ? "No password fields detected" : secure ? "Password fields detected on HTTPS" : "Password fields detected on HTTP",6)); + add(passwordFields > 0 && !secure,"Protect password forms with HTTPS", + "A password field was detected on an HTTP page. Serve the page and form over HTTPS.","high"); + + const externalScripts = [...source.matchAll(/]*src\s*=\s*["']https?:\/\/[^"']+["'][^>]*>/gi)]; + const scriptsWithoutIntegrity = externalScripts.filter(match => !/\bintegrity\s*=/.test(match[0])).length; + checks.push(check("scriptIntegrity","External script integrity", + "Subresource Integrity can help verify externally hosted scripts have not changed unexpectedly.", + externalScripts.length === 0 || scriptsWithoutIntegrity === 0 ? "pass" : "warn", + externalScripts.length === 0 ? "No external scripts detected" : `${scriptsWithoutIntegrity}/${externalScripts.length} external scripts without integrity`,3)); + add(scriptsWithoutIntegrity > 0,"Review external script integrity", + `${scriptsWithoutIntegrity} external script(s) do not declare an integrity attribute. Consider SRI where supported.`); + + const unsafeCsp = csp && /(?:^|;)\s*script-src[^;]*(?:'unsafe-inline'|'unsafe-eval')/i.test(csp); + checks.push(check("cspUnsafeDirectives","CSP script directives", + "Avoid unsafe-inline and unsafe-eval in script-src where the application permits.", + !csp ? "info" : unsafeCsp ? "warn" : "pass", + !csp ? "CSP unavailable" : unsafeCsp ? "Potentially permissive script-src directive" : "No unsafe script directive detected",5)); + add(unsafeCsp,"Tighten Content-Security-Policy", + "The script-src directive includes unsafe-inline or unsafe-eval. Prefer nonces or hashes and remove unsafe directives when possible.","high"); + + const serverHeader = String(headers.server || ""); + const poweredBy = String(headers["x-powered-by"] || ""); + checks.push(check("serverDisclosure","Technology disclosure", + "Unnecessary server and framework version details can make fingerprinting easier.", + !serverHeader && !poweredBy ? "pass" : "warn", + [serverHeader && `Server: ${serverHeader}`,poweredBy && `X-Powered-By: ${poweredBy}`].filter(Boolean).join(" | ") || "No identifying headers disclosed",2)); + add(Boolean(serverHeader || poweredBy),"Reduce unnecessary technology disclosure", + "Review Server and X-Powered-By response headers and avoid exposing detailed version information."); + + return {checks,recommendations}; +} + +module.exports = {runSecurityAudit}; From 858f074d0df0ab09424434f91806906fcaaead8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:06:21 +0300 Subject: [PATCH 006/114] Keep informational security findings out of scoring --- security-audit.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/security-audit.js b/security-audit.js index 74e5c4b..d251d19 100644 --- a/security-audit.js +++ b/security-audit.js @@ -57,7 +57,7 @@ function runSecurityAudit({html="", headers={}, protocol="https:"}={}){ if(cookieCount){ checks.push(check("cookieSecure","Cookie Secure flag", "Session cookies sent over HTTPS should use the Secure attribute.", - secure && cookiesWithoutSecure === 0 ? "pass" : secure ? "warn" : "info", + secure && cookiesWithoutSecure === 0 ? "pass" : "warn", secure ? `${cookieCount - cookiesWithoutSecure}/${cookieCount} cookies marked Secure` : "Site uses HTTP",4)); checks.push(check("cookieHttpOnly","Cookie HttpOnly flag", "HttpOnly prevents JavaScript from reading cookies that do not need client-side access.", @@ -73,10 +73,6 @@ function runSecurityAudit({html="", headers={}, protocol="https:"}={}){ `${cookiesWithoutHttpOnly} cookie(s) were missing HttpOnly. Only add it where client-side access is not required.`); add(cookiesWithoutSameSite > 0,"Set explicit SameSite cookie policies", `${cookiesWithoutSameSite} cookie(s) did not declare SameSite.`); - }else{ - checks.push(check("cookies","Set-Cookie headers", - "Checks cookie attributes when the server sets cookies.", - "info","No Set-Cookie header on this response",1)); } const mixedResources = (source.match(/(?:src|href|poster)\s*=\s*["']http:\/\/[^"']+["']/gi) || []).length; @@ -118,8 +114,8 @@ function runSecurityAudit({html="", headers={}, protocol="https:"}={}){ const unsafeCsp = csp && /(?:^|;)\s*script-src[^;]*(?:'unsafe-inline'|'unsafe-eval')/i.test(csp); checks.push(check("cspUnsafeDirectives","CSP script directives", "Avoid unsafe-inline and unsafe-eval in script-src where the application permits.", - !csp ? "info" : unsafeCsp ? "warn" : "pass", - !csp ? "CSP unavailable" : unsafeCsp ? "Potentially permissive script-src directive" : "No unsafe script directive detected",5)); + !unsafeCsp ? "pass" : "warn", + !csp ? "CSP unavailable; review the CSP header check" : unsafeCsp ? "Potentially permissive script-src directive" : "No unsafe script directive detected",5)); add(unsafeCsp,"Tighten Content-Security-Policy", "The script-src directive includes unsafe-inline or unsafe-eval. Prefer nonces or hashes and remove unsafe directives when possible.","high"); From 4cedae28aaf1f358288354daa0ba5f74f77672d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:06:27 +0300 Subject: [PATCH 007/114] Integrate expanded passive security checks --- server.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/server.js b/server.js index addc02e..fa9f292 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ const fs = require("fs"); const path = require("path"); const dns = require("dns"); const net = require("net"); +const {runSecurityAudit} = require("./security-audit"); const PORT = Number(process.env.PORT || 3000); const PUBLIC_DIR = path.join(__dirname, "public"); @@ -382,6 +383,7 @@ async function analyze(rawUrl){ const final = new URL(page.finalUrl); const html = page.body || ""; const headers = page.headers; + const securityAudit = runSecurityAudit({html, headers, protocol:final.protocol}); const title = firstMatch(html, /]*>([\s\S]*?)<\/title>/i); const description = metaContent(html, "name", "description"); @@ -437,7 +439,8 @@ async function analyze(rawUrl){ makeCheck("security","csp","Content-Security-Policy","CSP ограничивает нежелательные источники контента.",csp ? "pass" : "warn",csp ? "Настроен" : "Не найден",7), makeCheck("security","hsts","HSTS","HSTS заставляет браузер использовать HTTPS.",hsts ? "pass" : final.protocol === "https:" ? "warn" : "fail",hsts ? "Настроен" : "Не найден",7), makeCheck("security","nosniff","X-Content-Type-Options","nosniff запрещает MIME-sniffing.",nosniff ? "pass" : "warn",nosniff ? "nosniff" : "Не найден",5), - makeCheck("security","referrer","Referrer-Policy","Политика referrer ограничивает передачу адреса источника.",referrer ? "pass" : "warn",headers["referrer-policy"] || "Не найден",5) + makeCheck("security","referrer","Referrer-Policy","Политика referrer ограничивает передачу адреса источника.",referrer ? "pass" : "warn",headers["referrer-policy"] || "Не найден",5), + ...securityAudit.checks ]; const categoryScores = { @@ -456,7 +459,7 @@ async function analyze(rawUrl){ let score = Math.round((earned / totalWeight) * 100); if(page.statusCode >= 400) score = Math.min(score, 30); - const recommendations = []; + const recommendations = [...securityAudit.recommendations]; const add = (condition,title,text,priority="medium") => { if(condition) recommendations.push({title,text,priority}); }; @@ -509,7 +512,14 @@ async function analyze(rawUrl){ cacheControl:headers["cache-control"] || "", csp:headers["content-security-policy"] || "", hsts:headers["strict-transport-security"] || "", - referrerPolicy:headers["referrer-policy"] || "" + referrerPolicy:headers["referrer-policy"] || "", + xFrameOptions:headers["x-frame-options"] || "", + permissionsPolicy:headers["permissions-policy"] || "", + crossOriginOpenerPolicy:headers["cross-origin-opener-policy"] || "", + crossOriginEmbedderPolicy:headers["cross-origin-embedder-policy"] || "", + crossOriginResourcePolicy:headers["cross-origin-resource-policy"] || "", + setCookieCount:Array.isArray(headers["set-cookie"]) ? headers["set-cookie"].length : headers["set-cookie"] ? 1 : 0, + xPoweredBy:headers["x-powered-by"] || "" } }; } From 60c1d3ebfb754cc0b6da2a134fff8c9d46175d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:06:47 +0300 Subject: [PATCH 008/114] Add tests for passive security audit --- test/security-audit.test.js | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 test/security-audit.test.js diff --git a/test/security-audit.test.js b/test/security-audit.test.js new file mode 100644 index 0000000..7645c2b --- /dev/null +++ b/test/security-audit.test.js @@ -0,0 +1,59 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const {runSecurityAudit} = require("../security-audit"); + +test("detects mixed content and insecure form actions", () => { + const result = runSecurityAudit({ + protocol:"https:", + html:'
' + }); + assert.equal(result.checks.find(item => item.key === "mixedContent").status, "warn"); + assert.equal(result.checks.find(item => item.key === "insecureForms").status, "fail"); + assert.ok(result.recommendations.some(item => item.title === "Remove mixed content")); +}); + +test("detects password fields on an HTTP page", () => { + const result = runSecurityAudit({ + protocol:"http:", + html:'
' + }); + assert.equal(result.checks.find(item => item.key === "passwordFormTransport").status, "fail"); +}); + +test("recognizes secure cookie attributes and framing protection", () => { + const result = runSecurityAudit({ + protocol:"https:", + headers:{ + "set-cookie":[ + "session=abc; Secure; HttpOnly; SameSite=Lax" + ], + "content-security-policy":"default-src 'self'; frame-ancestors 'none'" + }, + html:"Safe sample" + }); + assert.equal(result.checks.find(item => item.key === "cookieSecure").status, "pass"); + assert.equal(result.checks.find(item => item.key === "cookieHttpOnly").status, "pass"); + assert.equal(result.checks.find(item => item.key === "cookieSameSite").status, "pass"); + assert.equal(result.checks.find(item => item.key === "frameProtection").status, "pass"); +}); + +test("flags unsafe CSP script directives", () => { + const result = runSecurityAudit({ + protocol:"https:", + headers:{"content-security-policy":"default-src 'self'; script-src 'self' 'unsafe-inline'"}, + html:"" + }); + assert.equal(result.checks.find(item => item.key === "cspUnsafeDirectives").status, "warn"); +}); + +test("does not flag a page without cookies or external scripts as insecure", () => { + const result = runSecurityAudit({ + protocol:"https:", + headers:{"content-security-policy":"default-src 'self'"}, + html:"Static content" + }); + assert.equal(result.checks.some(item => item.key === "cookieSecure"), false); + assert.equal(result.checks.find(item => item.key === "scriptIntegrity").status, "pass"); +}); From c6dd25381ee526308b12cbc3a09e8855959bdea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:07:00 +0300 Subject: [PATCH 009/114] Add test and syntax check scripts --- package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cfb17be..aee7d23 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,11 @@ "main": "server.js", "scripts": { "start": "node server.js", - "dev": "node --watch server.js" + "dev": "node --watch server.js", + "test": "node --test", + "check": "node --check server.js && node --check security-audit.js && node --check public/app.js && node --check public/i18n.js" }, "engines": { "node": ">=18" } -} \ No newline at end of file +} From 1e5f4cc82fd0e33cd09f67906f05d11bc597f91a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:07:02 +0300 Subject: [PATCH 010/114] Fix CI workflow and run tests without npm install --- .github/workflows/node.js.yml | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 2284b93..cd502a7 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -1,31 +1,24 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs - name: Node.js CI on: push: - branches: [ "main" ] + branches: ["main", "upgrade/**"] pull_request: - branches: [ "main" ] + branches: ["main"] jobs: - build: - + test: runs-on: ubuntu-latest - strategy: matrix: node-version: [18.x, 20.x, 22.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run build --if-present - - run: npm test + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Check JavaScript syntax + run: npm run check + - name: Run tests + run: npm test From e50c07bd7a17766266551b8519fdc320c3951558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:07:58 +0300 Subject: [PATCH 011/114] Translate expanded security audit findings --- public/i18n.js | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/public/i18n.js b/public/i18n.js index 7ddb9c4..316c6a3 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -45,6 +45,46 @@ "Не указан":"Not specified", "Не указана":"Not specified", "Не указано":"Not specified", + "X-Frame-Options or CSP frame-ancestors helps control whether other sites can embed this page.":"X-Frame-Options or CSP frame-ancestors helps control whether other sites can embed this page.", + "Controls access to browser features such as camera, microphone and geolocation.":"Controls access to browser features such as camera, microphone and geolocation.", + "Helps isolate the browsing context from cross-origin windows.":"Helps isolate the browsing context from cross-origin windows.", + "Controls which cross-origin resources may be embedded.":"Controls which cross-origin resources may be embedded.", + "Controls which origins may load this resource.":"Controls which origins may load this resource.", + "Session cookies sent over HTTPS should use the Secure attribute.":"Session cookies sent over HTTPS should use the Secure attribute.", + "HttpOnly prevents JavaScript from reading cookies that do not need client-side access.":"HttpOnly prevents JavaScript from reading cookies that do not need client-side access.", + "SameSite helps control when browsers attach cookies to cross-site requests.":"SameSite helps control when browsers attach cookies to cross-site requests.", + "HTTPS pages should avoid loading HTTP resources that browsers may block or downgrade.":"HTTPS pages should avoid loading HTTP resources that browsers may block or downgrade.", + "Forms should not submit sensitive data to an unencrypted HTTP endpoint.":"Forms should not submit sensitive data to an unencrypted HTTP endpoint.", + "Password fields on a page served over HTTP expose credentials in transit.":"Password fields on a page served over HTTP expose credentials in transit.", + "Subresource Integrity can help verify externally hosted scripts have not changed unexpectedly.":"Subresource Integrity can help verify externally hosted scripts have not changed unexpectedly.", + "Avoid unsafe-inline and unsafe-eval in script-src where the application permits.":"Avoid unsafe-inline and unsafe-eval in script-src where the application permits.", + "Unnecessary server and framework version details can make fingerprinting easier.":"Unnecessary server and framework version details can make fingerprinting easier.", + "Add anti-framing protection":"Add anti-framing protection", + "Configure CSP frame-ancestors or X-Frame-Options to reduce clickjacking risk.":"Configure CSP frame-ancestors or X-Frame-Options to reduce clickjacking risk.", + "Review Permissions-Policy":"Review Permissions-Policy", + "Set an explicit Permissions-Policy that disables browser features the site does not need.":"Set an explicit Permissions-Policy that disables browser features the site does not need.", + "Review cookie Secure flags":"Review cookie Secure flags", + "Review cookie HttpOnly flags":"Review cookie HttpOnly flags", + "Set explicit SameSite cookie policies":"Set explicit SameSite cookie policies", + "Remove mixed content":"Remove mixed content", + "Fix insecure form destinations":"Fix insecure form destinations", + "Protect password forms with HTTPS":"Protect password forms with HTTPS", + "Review external script integrity":"Review external script integrity", + "Tighten Content-Security-Policy":"Tighten Content-Security-Policy", + "Reduce unnecessary technology disclosure":"Reduce unnecessary technology disclosure", + "Не настроен":"Not configured", + "Настроен":"Configured", + "Не найдено":"Not found", + "Найдено":"Found", + "Найдено":"Found", + "No HTTP resource URLs found in markup":"No HTTP resource URLs found in markup", + "No explicit HTTP form actions found":"No explicit HTTP form actions found", + "No password fields detected":"No password fields detected", + "No external scripts detected":"No external scripts detected", + "Not configured":"Not configured", + "Не найдено":"Not found", + "Не обнаружено":"Not detected", + "Не найден":"Not found", "Доступен":"Available", "Найдена":"Found", "Настроен":"Configured", From c4a7e83d9cda116c4bf7e42a82416eb99506ee18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:09:17 +0300 Subject: [PATCH 012/114] Document bilingual UI and expanded passive security audit --- README.md | 67 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index c8b61c0..f045d2e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Проверка SEO, security, social metadata и технического состояния сайта — в одном понятном отчёте.** -![Version](https://img.shields.io/badge/version-0.2.1-265CFF?style=flat-square) +![Version](https://img.shields.io/badge/version-0.3.0-265CFF?style=flat-square) ![Node](https://img.shields.io/badge/Node.js-18%2B-111318?style=flat-square) ![License](https://img.shields.io/badge/license-MIT-FF5B2E?style=flat-square) ![Status](https://img.shields.io/badge/status-active-168A58?style=flat-square) @@ -70,17 +70,19 @@ WebCheck не требует базы данных, регистрации по ## Security -WebCheck проверяет базовые security-сигналы: +Расширенный пассивный Security Audit проверяет публично доступные HTTP-данные и HTML-разметку: -```text -HTTPS -Content-Security-Policy -Strict-Transport-Security -X-Content-Type-Options -Referrer-Policy -``` +- HTTPS и основные security headers: CSP, HSTS, X-Content-Type-Options, Referrer-Policy; +- clickjacking protection: X-Frame-Options и CSP frame-ancestors; +- Permissions-Policy, COOP, COEP и CORP; +- атрибуты Set-Cookie: Secure, HttpOnly и SameSite; +- mixed content (HTTP-ресурсы на HTTPS-странице); +- небезопасные form actions и password-поля на HTTP; +- внешние скрипты без Subresource Integrity; +- потенциально небезопасные CSP script-src директивы; +- избыточное раскрытие технологий через Server и X-Powered-By. -> WebCheck не является полноценным сканером уязвимостей и не пытается взламывать или атаковать сайт. Это диагностический инструмент для проверки публично доступных HTTP-данных. +Проверки неэксплуатационные: WebCheck не отправляет exploit payloads, не перебирает учётные данные и не пытается получить доступ к закрытым ресурсам. Результаты являются сигналами для ручной проверки, а не подтверждением отсутствия уязвимостей. ## Social @@ -329,7 +331,7 @@ WebCheck/ - отчёт; - мобильную адаптацию. -### `public/app.js` +### `public/i18n.js`\n\nПереключение русского и английского интерфейса с сохранением выбора в браузере.\n\n### `public/app.js` Frontend-логика: @@ -604,39 +606,38 @@ POST /api/check # 12 / Ограничения текущей версии -WebCheck v0.2.1 — это быстрый диагностический инструмент. +WebCheck v0.3.0 — это пассивный диагностический инструмент, а не полноценный penetration-testing scanner. Он пока не выполняет: - JavaScript rendering страницы; -- Lighthouse audit; -- Core Web Vitals из реальных пользователей; -- глубокое сканирование всего сайта; -- проверку всех внутренних ссылок; -- поиск уязвимостей; -- анализ JavaScript bundle; +- Lighthouse audit и измерение Core Web Vitals; +- глубокое сканирование всего сайта и всех внутренних ссылок; +- активную эксплуатацию уязвимостей или fuzzing; +- анализ исходного кода закрытого backend; - полноценный accessibility audit. -Большая часть текущих проверок выполняется по HTML и HTTP headers главной страницы. +Security-результаты строятся по HTML и HTTP headers, доступным без аутентификации. Отсутствие найденного сигнала не означает, что сайт защищён от всех атак. --- # 13 / Roadmap -```text -[ ] OpenGraph visual preview -[ ] Twitter / X card diagnostics -[ ] Broken links scanner -[ ] Lighthouse integration -[ ] Core Web Vitals -[ ] Better HTML parser -[ ] JSON report export -[ ] PDF report export -[ ] Scan history -[ ] Shareable report -[ ] Rate limiting -[ ] Docker image -``` +- [x] Russian / English interface switch +- [x] Expanded passive Security Audit +- [x] Security audit unit tests and CI checks +- [ ] OpenGraph visual preview +- [ ] Twitter / X card diagnostics +- [ ] Broken links scanner +- [ ] Lighthouse integration +- [ ] Core Web Vitals +- [ ] Better HTML parser +- [ ] JSON report export +- [ ] PDF report export +- [ ] Scan history +- [ ] Shareable report +- [ ] Rate limiting +- [ ] Docker image --- From f381cd3059636073fe6a7ede1055a6e926bee4ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:09:19 +0300 Subject: [PATCH 013/114] Bump WebCheck version to 0.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aee7d23..6e5206c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webcheck", - "version": "0.2.1", + "version": "0.3.0", "private": true, "description": "Clear website diagnostics for SEO, security, social metadata and technical health.", "main": "server.js", From 5dbd6e2436ca12e0343680fe5336754f78702f9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:09:21 +0300 Subject: [PATCH 014/114] Update displayed WebCheck version --- public/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/index.html b/public/index.html index 80a821f..9bad3d1 100644 --- a/public/index.html +++ b/public/index.html @@ -23,7 +23,7 @@
ENGINE ONLINE - V0.2.1 + V0.3.0
From 796ab30072eee1476cea724a3340aafc5e072be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 18:09:23 +0300 Subject: [PATCH 015/114] Update backend version banner --- server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.js b/server.js index fa9f292..ddd35a2 100644 --- a/server.js +++ b/server.js @@ -625,5 +625,5 @@ const server = http.createServer((req, res) => { }); server.listen(PORT, "0.0.0.0", () => { - console.log(`WebCheck v0.2.1 running on http://localhost:${PORT}`); + console.log(`WebCheck v0.3.0 running on http://localhost:${PORT}`); }); From e90dbc14ae773aa0111e3bfaae8bd51ca5f4ec1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 19:09:03 +0300 Subject: [PATCH 016/114] Add deeper SEO page diagnostics --- seo-audit.js | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 seo-audit.js diff --git a/seo-audit.js b/seo-audit.js new file mode 100644 index 0000000..f118676 --- /dev/null +++ b/seo-audit.js @@ -0,0 +1,114 @@ +"use strict"; + +function parseAttributes(tag){ + const attributes = {}; + const pattern = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; + let match; + while((match=pattern.exec(tag))){ + const name=match[1].toLowerCase(); + if(name.startsWith("<")) continue; + attributes[name]=match[2] ?? match[3] ?? match[4] ?? ""; + } + return attributes; +} + +function findMeta(html,key,value){ + const tags=String(html).match(/]*>/gi)||[]; + for(const tag of tags){ + const attrs=parseAttributes(tag); + if(String(attrs[key]||"").toLowerCase()===value.toLowerCase()) return attrs.content||""; + } + return ""; +} + +function runSeoAudit({html="",finalUrl=""}={}){ + const source=String(html); + const checks=[]; + const recommendations=[]; + const addCheck=(key,label,description,status,value,weight=3)=>{ + checks.push({category:"seo",key,label,description,status,value,weight}); + }; + const add=(condition,title,text,priority="medium")=>{ + if(condition) recommendations.push({title,text,priority}); + }; + const titleMatch=source.match(/]*>([\s\S]*?)<\/title>/i); + const title=titleMatch?titleMatch[1].replace(/<[^>]+>/g," ").replace(/\s+/g," ").trim():""; + const description=findMeta(source,"name","description").trim(); + const canonicalTag=(source.match(/]*>/gi)||[]).find(tag=>/\brel\s*=\s*["'][^"']*\bcanonical\b/i.test(tag)); + const canonical=canonicalTag?parseAttributes(canonicalTag).href||"":""; + const robots=findMeta(source,"name","robots").toLowerCase(); + const headingTags=[...source.matchAll(/]*>/gi)].map(match=>Number(match[1])); + const jsonLd=[...source.matchAll(/]*)>([\s\S]*?)<\/script>/gi)] + .filter(match=>/type\s*=\s*["']application\/ld\+json["']/i.test(match[1])); + let validJsonLd=0; + let invalidJsonLd=0; + for(const item of jsonLd){ + try{JSON.parse(item[2].trim());validJsonLd++;} + catch{invalidJsonLd++;} + } + const twitterCard=findMeta(source,"name","twitter:card"); + const hreflangCount=(source.match(/]*\bhreflang\s*=/gi)||[]).length; + + const titleLength=title.length; + addCheck("titleLength","Title length","A concise title is usually easier to understand in search results.", + titleLength>=30&&titleLength<=60?"pass":"warn",title?titleLength+" characters":"No title to measure",4); + add(title&&(titleLength<30||titleLength>60),"Improve title length", + "The title contains "+titleLength+" characters. Review it for clarity and avoid unnecessary truncation."); + + const descriptionLength=description.length; + addCheck("descriptionLength","Description length","A useful description should summarize the page without excessive truncation.", + descriptionLength>=70&&descriptionLength<=160?"pass":"warn",description?descriptionLength+" characters":"Not provided",4); + add(!description||descriptionLength<70||descriptionLength>160,"Review meta description", + description?"The description contains "+descriptionLength+" characters. Make it concise and informative.":"Add a unique description that accurately summarizes the page."); + + const noindex=/(?:^|[,\s])noindex(?:$|[,\s])/i.test(robots); + const nofollow=/(?:^|[,\s])nofollow(?:$|[,\s])/i.test(robots); + addCheck("robotsMeta","Robots meta directive","Checks whether the page explicitly asks search engines not to index or follow it.", + noindex?"warn":"pass",robots||"No restrictive robots directive",5); + add(noindex,"Review noindex directive", + "The page includes a noindex directive. Confirm that search engines are intentionally prevented from indexing it.","high"); + if(nofollow) addCheck("nofollowMeta","Nofollow directive","The page asks crawlers not to follow its links.", + "warn",robots,2); + + let canonicalStatus="pass"; + let canonicalValue="Not specified"; + if(canonical){ + try{ + const canonicalUrl=new URL(canonical,finalUrl); + const pageUrl=new URL(finalUrl); + canonicalValue=canonicalUrl.toString(); + if(!["https:","http:"].includes(canonicalUrl.protocol)||canonicalUrl.hostname!==pageUrl.hostname|| + canonicalUrl.pathname!==pageUrl.pathname||canonicalUrl.search!==pageUrl.search) canonicalStatus="warn"; + }catch{canonicalStatus="fail";canonicalValue="Invalid canonical URL";} + } + addCheck("canonicalConsistency","Canonical consistency","Checks whether the canonical URL is valid and points to the same page host and path.", + canonicalStatus,canonicalValue,4); + add(canonicalStatus!=="pass","Review canonical URL", + "Verify that the canonical URL resolves correctly and points to the intended preferred page."); + + const skips=[]; + for(let index=1;indexheadingTags[index-1]+1) skips.push(headingTags[index-1]+"→"+headingTags[index]); + } + addCheck("headingHierarchy","Heading hierarchy","Heading levels should form a logical outline without skipping levels.", + skips.length?"warn":"pass",headingTags.length?headingTags.length+" headings"+(skips.length?"; skips "+skips.join(", "):""):"No headings found",3); + add(skips.length>0,"Fix heading hierarchy","Heading levels skip from "+skips.join(", ")+". Use headings in a logical order."); + + const schemaStatus=invalidJsonLd>0?"warn":validJsonLd>0?"pass":"warn"; + addCheck("structuredData","Structured data (JSON-LD)","Checks whether JSON-LD structured data is present and parses as JSON.", + schemaStatus,validJsonLd?validJsonLd+" valid block(s)":invalidJsonLd?invalidJsonLd+" invalid block(s)":"No JSON-LD found",3); + add(invalidJsonLd>0,"Fix invalid structured data",invalidJsonLd+" JSON-LD block(s) could not be parsed as JSON."); + add(validJsonLd===0&&invalidJsonLd===0,"Consider structured data","Add relevant Schema.org JSON-LD where it accurately describes the page."); + + addCheck("twitterCard","Twitter/X card metadata","A card declaration helps control link previews on supported social platforms.", + twitterCard?"pass":"warn",twitterCard||"Not provided",2); + add(!twitterCard,"Add Twitter/X card metadata", + "Consider adding twitter:card and, where relevant, twitter:title, twitter:description and twitter:image."); + + addCheck("hreflang","International language alternates","hreflang annotations help search engines understand localized page versions.", + hreflangCount>0?"pass":"warn",hreflangCount?hreflangCount+" alternate link(s)":"None declared",2); + + return {checks,recommendations,meta:{title,description,robots,canonical,twitterCard,validJsonLd,invalidJsonLd,hreflangCount}}; +} + +module.exports={runSeoAudit,parseAttributes,findMeta}; From 8d03874b0bedce49c8e99ba248eaa8d53ea3e2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 19:09:07 +0300 Subject: [PATCH 017/114] Add page weight and loading diagnostics --- performance-audit.js | 71 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 performance-audit.js diff --git a/performance-audit.js b/performance-audit.js new file mode 100644 index 0000000..6c50b1e --- /dev/null +++ b/performance-audit.js @@ -0,0 +1,71 @@ +"use strict"; + +function runPerformanceAudit({html="",htmlBytes=0,responseTimeMs=0,headers={}}={}){ + const source=String(html); + const checks=[]; + const recommendations=[]; + const addCheck=(key,label,description,status,value,weight=3)=>{ + checks.push({category:"performance",key,label,description,status,value,weight}); + }; + const add=(condition,title,text,priority="medium")=>{ + if(condition) recommendations.push({title,text,priority}); + }; + + const images=[...source.matchAll(/]*>/gi)]; + const scripts=[...source.matchAll(/]*>/gi)]; + const blockingScripts=scripts.filter(match=>!/\b(?:async|defer)\b/i.test(match[0])&&!/\btype\s*=\s*["']module["']/i.test(match[0])); + const stylesheets=[...source.matchAll(/]*>/gi)] + .filter(match=>/\brel\s*=\s*["'][^"']*\bstylesheet\b/i.test(match[0])&&!/\bmedia\s*=\s*["']print["']/i.test(match[0])); + const resourceCount=images.length+scripts.length+stylesheets.length; + const lazyImages=images.filter(match=>/\bloading\s*=\s*["']lazy["']/i.test(match[0])).length; + const dimensionlessImages=images.filter(match=>!/\bwidth\s*=/.test(match[0])||!/\bheight\s*=/.test(match[0])).length; + + addCheck("htmlWeight","HTML document size","Large HTML documents take longer to transfer and parse.", + htmlBytes<=100*1024?"pass":htmlBytes<=500*1024?"warn":"fail",(htmlBytes/1024).toFixed(1)+" KB",5); + add(htmlBytes>500*1024,"Reduce HTML document size", + "The HTML document is larger than 500 KB. Consider reducing inline markup and deferring non-critical content.","high"); + add(htmlBytes>100*1024&&htmlBytes<=500*1024,"Review HTML document size", + "The HTML document is larger than 100 KB. Check whether large inline data or duplicated markup can be reduced."); + + addCheck("timeToFirstByte","Time to first byte (TTFB)","Measures time until the first HTTP response headers arrive.", + responseTimeMs<=800?"pass":responseTimeMs<=1800?"warn":"fail",Math.round(responseTimeMs)+" ms",6); + add(responseTimeMs>1800,"Improve server response time", + "The initial server response is slow. Review hosting latency, backend work, caching and database queries.","high"); + + addCheck("resourceCount","Initial resource count","Counts images, scripts and stylesheet references in the HTML.", + resourceCount<=40?"pass":resourceCount<=80?"warn":"fail",resourceCount+" resource reference(s)",3); + add(resourceCount>80,"Reduce initial resource count", + "The page references "+resourceCount+" images, scripts and stylesheets. Reduce unnecessary requests or defer non-critical resources."); + + addCheck("blockingScripts","Parser-blocking scripts","Scripts without async/defer can delay HTML parsing.", + blockingScripts.length===0?"pass":"warn",blockingScripts.length+" blocking script tag(s)",4); + add(blockingScripts.length>0,"Defer non-critical JavaScript", + blockingScripts.length+" script tag(s) do not use async, defer or type=module. Review whether they can be deferred."); + + addCheck("lazyImages","Lazy-loaded images","Below-the-fold images can use loading=lazy to avoid competing with visible content.", + images.length===0||lazyImages>=Math.ceil(images.length/2)?"pass":"warn",lazyImages+"/"+images.length+" image(s) marked lazy",3); + add(images.length>2&&lazyImages0,"Set image dimensions", + dimensionlessImages+" image(s) do not declare both width and height. Add intrinsic dimensions to reduce layout shifts."); + + const cacheControl=String(headers["cache-control"]||""); + const hasCachePolicy=Boolean(cacheControl||headers.expires); + addCheck("cachePolicy","Cache policy header","Cache headers help browsers and CDNs reuse suitable responses.", + hasCachePolicy?"pass":"warn",cacheControl||(headers.expires?"Expires: "+headers.expires:"No cache policy header"),2); + add(!hasCachePolicy,"Review cache policy", + "No Cache-Control or Expires header was observed on the main HTML response. Configure caching according to whether the page is static or personalized."); + + const encoding=String(headers["content-encoding"]||"identity"); + addCheck("contentEncoding","Response content encoding","Compression can reduce transfer size for text-based responses.", + /^(br|gzip|deflate)$/i.test(encoding)?"pass":"warn",encoding,2); + add(!/^(br|gzip|deflate)$/i.test(encoding),"Enable text response compression", + "The response did not advertise gzip, deflate or Brotli encoding. Consider enabling compression at the web server or CDN."); + + return {checks,recommendations,metrics:{resourceCount,images:images.length,scripts:scripts.length,stylesheets:stylesheets.length,blockingScripts:blockingScripts.length,lazyImages}}; +} + +module.exports={runPerformanceAudit}; From f84747818e76ab6da0568f67d70e2f76d4de2b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B9PlayMedia?= Date: Sun, 1 Nov 2026 19:09:09 +0300 Subject: [PATCH 018/114] Add practical accessibility diagnostics --- accessibility-audit.js | 90 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 accessibility-audit.js diff --git a/accessibility-audit.js b/accessibility-audit.js new file mode 100644 index 0000000..726d19e --- /dev/null +++ b/accessibility-audit.js @@ -0,0 +1,90 @@ +"use strict"; + +const {parseAttributes}=require("./seo-audit"); + +function runAccessibilityAudit({html=""}={}){ + const source=String(html); + const checks=[]; + const recommendations=[]; + const addCheck=(key,label,description,status,value,weight=3)=>{ + checks.push({category:"accessibility",key,label,description,status,value,weight}); + }; + const add=(condition,title,text,priority="medium")=>{ + if(condition) recommendations.push({title,text,priority}); + }; + + const images=[...source.matchAll(/]*>/gi)].map(match=>parseAttributes(match[0])); + const missingAlt=images.filter(attrs=>!("alt" in attrs)).length; + addCheck("imageAlt","Image alternative text","Informative images need useful alt text; decorative images should use alt=\"\".", + missingAlt===0?"pass":"warn",missingAlt+"/"+images.length+" image(s) missing alt",5); + add(missingAlt>0,"Add image alternative text", + missingAlt+" image(s) do not declare an alt attribute. Add meaningful alt text or an empty alt for decorative images."); + + const ids=[...source.matchAll(/\bid\s*=\s*["']([^"']+)["']/gi)].map(match=>match[1]); + const duplicates=[...new Set(ids.filter((id,index)=>ids.indexOf(id)!==index))]; + addCheck("uniqueIds","Unique element IDs","Duplicate IDs can break labels, scripts and assistive-technology references.", + duplicates.length===0?"pass":"fail",duplicates.length?duplicates.join(", "):ids.length+" unique ID(s)",4); + add(duplicates.length>0,"Make element IDs unique","Duplicate IDs found: "+duplicates.join(", ")+".","high"); + + const labels=[...source.matchAll(/]*)>([\s\S]*?)<\/label>/gi)]; + const explicitLabelIds=new Set(labels.map(match=>parseAttributes("