import fs from 'node:fs/promises'; import { DOMParser } from 'linkedom'; const base = (process.env.VERIFY_URL || 'http://127.0.0.1:8080').replace(/\/$/, ''); const fetchPage = async (path, method = 'GET') => { const response = await fetch(base + path, { method, redirect: 'manual', signal: AbortSignal.timeout(15000) }); return { status: response.status, location: response.headers.get('location'), text: method === 'GET' ? await response.text() : '' }; }; async function pool(values, work) { let next = 0; await Promise.all(Array.from({ length: 2 }, async () => { while (next < values.length) await work(values[next++]); })); } const sitemap = new DOMParser().parseFromString((await fetchPage('/sitemap.xml')).text, 'text/xml'); const paths = [...sitemap.querySelectorAll('loc')].map((n) => new URL(n.textContent).pathname); const canonicalOrigin = new URL(sitemap.querySelector('loc').textContent).origin; const documents = new Map(), links = new Map(), media = new Map(); const add = (map, url, source) => { if (!map.has(url)) map.set(url, new Set()); map.get(url).add(source); }; await pool(paths, async (path) => { const result = await fetchPage(path); documents.set(path, result); if (result.status !== 200) return; const doc = new DOMParser().parseFromString(result.text, 'text/html'); for (const el of doc.querySelectorAll('a[href]')) { const raw = el.getAttribute('href'); let url; try { url = new URL(raw, canonicalOrigin + path); } catch { continue; } if (![canonicalOrigin, base, 'https://danamedical.ru', 'http://danamedical.ru'].includes(url.origin)) continue; if (/^\/(api|admin)(\/|$)/.test(url.pathname)) continue; add(links, url.pathname + url.hash, path); } for (const el of doc.querySelectorAll('img[src],script[src],link[rel="stylesheet"][href]')) { const raw = el.getAttribute('src') || el.getAttribute('href'); const url = new URL(raw, canonicalOrigin + path); if ([canonicalOrigin, base].includes(url.origin)) add(media, url.pathname, path); } }); const issues = [], redirects = []; await pool([...links.keys()], async (link) => { const url = new URL(link, base), path = url.pathname; let result = documents.get(path); if (!result) { result = await fetchPage(path); documents.set(path, result); } if (result.status >= 300 && result.status < 400) redirects.push({ url: link, to: result.location, sources: [...links.get(link)] }); else if (result.status !== 200) issues.push({ type: 'link', url: link, status: result.status, sources: [...links.get(link)] }); else if (url.hash) { const doc = new DOMParser().parseFromString(result.text, 'text/html'); const anchor = decodeURIComponent(url.hash.slice(1)); if (!doc.getElementById(anchor) && ![...doc.querySelectorAll('a[name]')].some((el) => el.getAttribute('name') === anchor)) { issues.push({ type: 'anchor', url: link, status: 200, sources: [...links.get(link)] }); } } }); await pool([...media.keys()], async (url) => { const result = await fetchPage(url, 'HEAD'); if (result.status !== 200) issues.push({ type: 'asset', url, status: result.status, sources: [...media.get(url)] }); }); const report = { checkedAt: new Date().toISOString(), base, pages: paths.length, internalLinks: links.size, assets: media.size, issues, redirects }; await fs.writeFile('../seo/crawl-audit.json', JSON.stringify(report, null, 2)); console.log(JSON.stringify({ pages: report.pages, internalLinks: report.internalLinks, assets: report.assets, issues: issues.map(({ type, url, status }) => ({ type, url, status })), redirects: redirects.map(({ url, to }) => ({ url, to })) }, null, 2)); if (process.env.STRICT_AUDIT === '1' && issues.length) process.exitCode = 1;