@operstack/audit 0.36.2 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +10 -0
  2. package/bin/audit.mjs +2 -2
  3. package/package.json +5 -3
  4. package/src/client-eye.mjs +389 -0
  5. package/src/collect.mjs +171 -85
  6. package/src/fix.mjs +124 -5
  7. package/src/foundation.mjs +2 -1
  8. package/src/i18n.mjs +50 -25
  9. package/src/index.mjs +2 -1
  10. package/src/narrative.mjs +56 -25
  11. package/src/niche.mjs +13 -12
  12. package/src/pagekind.mjs +18 -1
  13. package/src/presentation.mjs +3 -2
  14. package/src/prompts.mjs +163 -42
  15. package/src/render.mjs +61 -30
  16. package/src/rendered.mjs +6 -2
  17. package/src/tasks-pdf.mjs +10 -5
  18. package/src/verify.mjs +26 -13
  19. package/src/visibility.mjs +137 -45
  20. package/src/vy.mjs +13 -0
  21. package/src/test-agency-ids.mjs +0 -48
  22. package/src/test-agency.mjs +0 -158
  23. package/src/test-agent-surface.mjs +0 -46
  24. package/src/test-answer-first.mjs +0 -27
  25. package/src/test-blocked.mjs +0 -100
  26. package/src/test-crawl-links.mjs +0 -87
  27. package/src/test-demand.mjs +0 -243
  28. package/src/test-etalon.mjs +0 -42
  29. package/src/test-fix.mjs +0 -115
  30. package/src/test-flaky-files.mjs +0 -81
  31. package/src/test-foundation.mjs +0 -126
  32. package/src/test-handover.mjs +0 -148
  33. package/src/test-i18n.mjs +0 -68
  34. package/src/test-narrative.mjs +0 -117
  35. package/src/test-overall.mjs +0 -236
  36. package/src/test-planted.mjs +0 -114
  37. package/src/test-presentation.mjs +0 -67
  38. package/src/test-prompts.mjs +0 -142
  39. package/src/test-prospect-count.mjs +0 -12
  40. package/src/test-redirect-host.mjs +0 -63
  41. package/src/test-rendered.mjs +0 -136
  42. package/src/test-report-wording.mjs +0 -35
  43. package/src/test-ru-report.mjs +0 -95
  44. package/src/test-ru-sources.mjs +0 -41
  45. package/src/test-sample-fairness.mjs +0 -50
  46. package/src/test-sample.mjs +0 -91
  47. package/src/test-scores.mjs +0 -75
  48. package/src/test-shopify-store.mjs +0 -159
  49. package/src/test-sitemap.mjs +0 -91
  50. package/src/test-unmeasured.mjs +0 -173
@@ -1,91 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Прочитать меньше не должно выглядеть как «стало лучше».
4
- *
5
- * 15.09.2026 один и тот же сайт в шести прогонах подряд дал 46, 54, 59 и 69 баллов. Выше он
6
- * выходил там, где из выборки успела ответить одна страница вместо двух: области «есть ли что
7
- * процитировать» и «дата и доверие» считались по одной главной, а она у большинства сайтов
8
- * лучше внутренних страниц. То есть медленная сеть выглядела как хороший сайт, и покупатель,
9
- * запустивший проверку дважды, видел два разных числа.
10
- *
11
- * Теперь: страниц хватало, а ответили не все, значит эти две области не измерены. Балл считается
12
- * по остальным и переносится на сто, как и при нечитаемом robots.txt. Здесь это держится на
13
- * живом сервере: одна страница выборки отвечает сразу, вторая висит дольше отпущенного времени.
14
- */
15
- import { createServer } from 'node:http';
16
- import { lookup } from 'node:dns/promises';
17
- import { checkVisibility } from './visibility.mjs';
18
-
19
- let bad = 0;
20
- const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
21
- const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
22
-
23
- const page = (title, extra = '') => `<!doctype html><html lang="ru"><head><title>${title}</title>
24
- <meta name="description" content="Описание страницы для проверки, достаточно длинное чтобы не считаться пустым.">
25
- <link rel="canonical" href="http://example.com/"></head><body><main><h1>${title}</h1>
26
- ${'<p>Обычный абзац текста про товар, услугу и цену без источника.</p>'.repeat(12)}${extra}</main></body></html>`;
27
-
28
- /** Сервер: главная и две страницы в карте сайта, вторая из них отвечает медленнее отпущенного. */
29
- const serve = ({ slowPages = 0, slowMs = 9000 } = {}) => new Promise((resolve) => {
30
- const s = createServer((req, res) => {
31
- const send = (code, type, body) => { res.writeHead(code, { 'content-type': type }); res.end(body); };
32
- if (req.url.startsWith('/robots.txt')) return send(200, 'text/plain', `User-agent: *\nAllow: /\nSitemap: http://lvh.me:${s.address().port}/sitemap.xml\n`);
33
- if (req.url.startsWith('/llms.txt')) return send(404, 'text/plain', 'no');
34
- if (req.url.startsWith('/sitemap.xml')) {
35
- const base = `http://lvh.me:${s.address().port}`;
36
- // Восемь внутренних страниц: движок сортирует их по длине адреса и берёт каждую четвёртую,
37
- // поэтому выборка из двух складывается только на сайте такого размера.
38
- const urls = ['/', ...Array.from({ length: 8 }, (_, i) => `/stranica-nomer-${i}${'-x'.repeat(8 - i)}/`)];
39
- return send(200, 'application/xml', `<?xml version="1.0"?><urlset>${urls.map((u) => `<url><loc>${base}${u}</loc></url>`).join('')}</urlset>`);
40
- }
41
- if (req.url === '/') return send(200, 'text/html; charset=utf-8', page('Главная'));
42
- if (req.url.startsWith('/stranica-')) {
43
- if (slowPages > 0) { setTimeout(() => send(200, 'text/html; charset=utf-8', page('Медленная')), slowMs); return; }
44
- return send(200, 'text/html; charset=utf-8', page('Внутренняя'));
45
- }
46
- return send(404, 'text/plain', 'no');
47
- });
48
- s.listen(0, '127.0.0.1', () => resolve({ s, url: `http://lvh.me:${s.address().port}/` }));
49
- });
50
-
51
- let dnsOk = true;
52
- try { const a = await lookup('lvh.me'); dnsOk = a.address === '127.0.0.1'; } catch { dnsOk = false; }
53
- if (!dnsOk) { console.log('ok пропуск: lvh.me не указывает на 127.0.0.1 (нет сети)'); process.exit(0); }
54
-
55
- const area = (v, id) => (v.areas || []).find((a) => a.id === id);
56
-
57
- // ---- все страницы выборки ответили: области считаются
58
- {
59
- const { s, url } = await serve();
60
- const v = await checkVisibility(url, { lang: 'ru', samplePages: 2, budgetMs: 8000 });
61
- ok('сайт прочитан', v.ok);
62
- ok('обе области по страницам измерены', area(v, 'content').measured !== false && area(v, 'trust').measured !== false);
63
- ok('балл посчитан из всех пяти областей', typeof v.score === 'number' && v.score > 0);
64
- s.close();
65
- }
66
-
67
- // ---- страницы выборки не отвечают даже со второй попытки: балла нет вовсе
68
- {
69
- const { s, url } = await serve({ slowPages: 1 });
70
- const v = await checkVisibility(url, { lang: 'ru', samplePages: 2, budgetMs: 6000 });
71
- is('балл не выдан', v.ok, false);
72
- ok('сказано, сколько страниц ответило', /Из \d+ страниц ответили \d+/.test(v.error));
73
- ok('сказано, что число не показываем и почему', /защитить нельзя/.test(v.error));
74
- ok('предложено повторить', /через минуту/.test(v.error));
75
- ok('это не выдаётся за блокировку', v.blocked === false && v.challenged === false);
76
- ok('в сообщении нет null и undefined', !/null|undefined|NaN/.test(v.error));
77
- const en = await checkVisibility(url, { lang: 'en', samplePages: 2, budgetMs: 6000 });
78
- ok('по-английски то же самое', en.ok === false && /not enough of the site to score/.test(en.error) && !/[А-Яа-яЁё]/.test(en.error));
79
- s.close();
80
- }
81
-
82
- // ---- все страницы отвечают быстро: областям есть на чём считаться
83
- {
84
- const { s, url } = await serve();
85
- const v = await checkVisibility(url, { lang: 'ru', samplePages: 2, budgetMs: 8000 });
86
- ok('быстрый сайт измерен целиком', area(v, 'content').measured !== false && area(v, 'trust').measured !== false);
87
- s.close();
88
- }
89
-
90
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
91
- console.log('\nвыборка: прочитать меньше больше не выглядит как «стало лучше»');
@@ -1,75 +0,0 @@
1
- /**
2
- * The rule this file exists to defend: no number in a report is typed by a human.
3
- * Every score is computed from the collected checks, and an area with no checks is
4
- * printed as "not measured" rather than scored on an impression.
5
- */
6
- import { computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
7
- import { toHtml, checkNarrative } from './render.mjs';
8
-
9
- let failed = 0;
10
- const is = (name, actual, expected) => {
11
- const ok = JSON.stringify(actual) === JSON.stringify(expected);
12
- if (!ok) { failed++; console.error(`FAIL ${name}\n expected ${JSON.stringify(expected)}\n actual ${JSON.stringify(actual)}`); }
13
- else console.log(`ok ${name}`);
14
- };
15
- const ok = (name, cond) => is(name, Boolean(cond), true);
16
-
17
- const checks = [
18
- { group: 'technical', status: 'ok' }, { group: 'technical', status: 'ok' },
19
- { group: 'technical', status: 'warn' }, { group: 'technical', status: 'na' },
20
- { group: 'aeo', status: 'bad' }, { group: 'aeo', status: 'ok' },
21
- ];
22
- const { scores, scoreBasis } = computeScores(checks);
23
-
24
- is('ok 1, warn 0.5, bad 0, na ignored', scores['SEO, technical'], 8);
25
- is('an area with half its checks failing scores 5', scores['AEO, answers and snippets'], 5);
26
- is('na is not counted', scoreBasis['SEO, technical'].counted, 3);
27
- is('an area with no checks is not measured', scores['Off-page and trust'], null);
28
- is('and says why', scoreBasis['Off-page and trust'].note, 'not measured: this audit collected no check in this area');
29
- is('conversion is not measured either', scores['Conversion and UX'], null);
30
- is('every area is accounted for', Object.keys(scores).length, SCORE_AREAS.length);
31
- is('the basis is printable evidence', scoreBasis['AEO, answers and snippets'].note, '1 of 2 checks pass, 1 fails');
32
-
33
- // an area is scored as soon as its checks are collected, and only then
34
- const withOffpage = computeScores([
35
- ...checks,
36
- { group: 'offpage', status: 'ok' }, { group: 'offpage', status: 'warn' }, { group: 'offpage', status: 'na' },
37
- { group: 'conversion', status: 'bad' }, { group: 'conversion', status: 'ok' },
38
- ]);
39
- is('off-page is scored from its own checks', withOffpage.scores['Off-page and trust'], 8);
40
- is('and the na row is not counted', withOffpage.scoreBasis['Off-page and trust'].counted, 2);
41
- is('conversion is scored from its own checks', withOffpage.scores['Conversion and UX'], 5);
42
- is('an area with checks is never "not measured"', withOffpage.scoreBasis['Conversion and UX'].note, '1 of 2 checks pass, 1 fails');
43
-
44
- const typed = { checks, scores: { 'SEO, technical': 2, 'Off-page and trust': 6, 'Made-up area': 9 } };
45
- const problems = verifyScores(typed);
46
- ok('a typed score is caught', problems.some((p) => p.startsWith('SEO, technical:')));
47
- ok('a score for an unmeasured area is caught', problems.some((p) => p.startsWith('Off-page and trust:')));
48
- ok('an invented area is caught', problems.some((p) => p.includes('not one of the six audit areas')));
49
- is('a report that matches its checks has no problems', verifyScores({ checks, scores }), []);
50
-
51
- const audit = {
52
- meta: { host: 'example.com', collectedAt: '2026-09-11T00:00:00.000Z', tool: 'test', auditType: 'test' },
53
- client: { name: 'Example', subject: 'test', reportDate: '2026-09-11' },
54
- scores: { 'SEO, technical': 2, 'Off-page and trust': 2 },
55
- checks,
56
- summary: { lead: 'x', verdict: 'x', priorities: ['x'] },
57
- overview: { rows: [['a', 'b']], note: 'x' },
58
- critical: [{ title: 'An issue with no text', cost: 'what it costs', fix: 'how to fix it' }, { title: 'A bare headline' }],
59
- content: { strengths: ['x'], weaknesses: ['x'], gaps: ['x'] },
60
- aeo: { works: ['x'], blocks: ['x'], recommendation: 'x' },
61
- geo: { rows: [['a', 'b', 'c']], callout: 'x' },
62
- offpage: { listed: ['x'], note: 'x' },
63
- conversion: { rows: [['a', 'b']] },
64
- limitations: ['x'], roadmap: [{ badge: 'w', title: 't', items: ['x'] }], closing: 'x', sample: [],
65
- };
66
- const html = toHtml(audit);
67
- ok('the report prints the computed score, not the typed one', html.includes('8/10'));
68
- ok('the typed 2/10 never reaches the page', !html.includes('>2/10<'));
69
- ok('an unmeasured area says so', html.includes('not measured'));
70
- ok('the scorecard shows the count it came from', html.includes('2 of 3 checks pass'));
71
- ok('a critical issue prints what it costs and how to fix it', html.includes('What it costs') && html.includes('how to fix it'));
72
- ok('a critical issue with no body is reported', checkNarrative(audit).some((m) => m.startsWith('critical[1].text')));
73
-
74
- if (failed) { console.error(`\n${failed} test(s) failed`); process.exit(1); }
75
- console.log('\nall scoring tests pass');
@@ -1,159 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Магазин на Shopify, как brainmd.com 12.10.2026. Прогон 0.33.3 дал пять неправд о живом сайте:
4
- * 1. «язык не указан», хотя <html> разбит на строки и lang="en" в нём стоит;
5
- * 2. «в карте 349 страниц» при 1 357: из десяти файлов карты прочитаны шесть, статьи пропущены;
6
- * 3. «CMS не измерено», хотя в коде Shopify.shop и cdn.shopify.com;
7
- * 4. в выборку попали книги и диски вместо основных товаров, и FAQPage «не найдена»;
8
- * 5. балл видимости не посчитан: в выборку попали agents.md и вход в аккаунт.
9
- * Второй прогон того же дня нашёл ещё пять:
10
- * 6. «нет sameAs», хотя в JSON-LD семь профилей, только слеши записаны как \/;
11
- * 7. «мёртвая ссылка» в llms.txt с хвостовой обратной кавычкой из markdown;
12
- * 8. «цены нет» при ценах на каждом товаре; пустая корзина $0.00 при этом не цена;
13
- * 9. «на первом экране нечего нажать»: Add to cart не считалось, а меню съедало 15% разметки;
14
- * 10. свой домен с точкой на конце в списке чужих хостов llms.txt.
15
- * Здесь такой же магазин поднимается локально, и каждая из пяти неправд проверяется отдельно.
16
- */
17
- import { createServer } from 'node:http';
18
- import { lookup } from 'node:dns/promises';
19
- import { collect, detectStack, homeLinkRank } from './collect.mjs';
20
- import { checkVisibility, linksInText } from './visibility.mjs';
21
- import { localiseChecks } from './i18n.mjs';
22
- import { draftNarrative } from './narrative.mjs';
23
-
24
- let bad = 0;
25
- const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
26
- const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
27
-
28
- // ---- движок по следам в коде
29
- is('Shopify по Shopify.shop', detectStack('<script>Shopify.shop = "x.myshopify.com";</script>'), 'Shopify');
30
- is('Shopify по cdn.shopify.com', detectStack('<link href="//cdn.shopify.com/s/files/1/a.css">'), 'Shopify');
31
- is('WordPress по wp-content', detectStack('<img src="/wp-content/uploads/a.jpg">'), 'WordPress');
32
- is('без следов не угадываем', detectStack('<p>Shopify is a platform we like</p>'), '');
33
-
34
- // ---- 7, 10. Адреса из markdown без хвостов
35
- is('хвосты markdown отрезаны', linksInText('See `https://x.com/.well-known/ucp`. Home: https://x.com. Also (https://x.com/a), "https://x.com/b"'),
36
- ['https://x.com/.well-known/ucp', 'https://x.com', 'https://x.com/a', 'https://x.com/b']);
37
-
38
- // ---- порядок по ссылкам главной
39
- {
40
- const r = homeLinkRank('<a href="/products/a">1</a><a href="/products/a">2</a><a href="https://www.x.com/products/b/">3</a><a href="https://other.com/products/c">4</a>', 'https://x.com/', new Set(['x.com', 'www.x.com']));
41
- is('ссылки главной посчитаны', [r.get('https://x.com/products/a/'), r.get('https://www.x.com/products/b/'), r.has('https://other.com/products/c/')], [2, 1, false]);
42
- }
43
-
44
- const BOOKS = Array.from({ length: 12 }, (_, i) => `/products/book-${i + 1}`);
45
- const MAIN = ['/products/omega-power', '/products/memory-boost'];
46
- const ARTICLES = Array.from({ length: 980 }, (_, i) => `/blogs/news/post-${i + 1}`);
47
- const PAGES = ['/pages/contact', '/pages/about'];
48
- const CHILDREN = ['agentic_discovery', 'products_1?from=1&amp;to=2', 'pages_1', 'collections_1', 'blogs_1', 'articles_1', 'metaobject_pages_1', 'metaobject_pages_2', 'metaobject_pages_3'];
49
-
50
- const shopifyHead = (title, schema = '') => `<!doctype html>
51
- <html
52
- class="no-js market-us "
53
- lang="en"
54
-
55
- >
56
- <head><title>${title} | Brain Store supplements</title>
57
- <meta name="description" content="A description of the page that is long enough to count as a real one for the check.">
58
- <link rel="stylesheet" href="//cdn.shopify.com/s/files/1/theme.css">
59
- <script>var Shopify = Shopify || {}; Shopify.shop = "x09u0a-0i.myshopify.com";</script>${schema}</head>`;
60
- const sameAs = '<script type="application/ld+json">{"@context":"https:\\/\\/schema.org","@type":"Organization","name":"Brain Store","sameAs":["https:\\/\\/www.instagram.com\\/brainstore\\/","https:\\/\\/www.youtube.com\\/brainstore","https:\\/\\/www.facebook.com\\/brainstore"]}</script>';
61
- const cartDrawer = '<div class="drawer">Cart 0. Spend $0.00 more to reach free shipping!</div>';
62
- const bigMenu = `<header><div class="menu">${'<div class="item"><img src="/i.png" alt=""><span class="x"></span></div>'.repeat(400)}</div></header>`;
63
- const buyBox = `<div class="gallery">${'<img src="/g.jpg" alt="">'.repeat(60)}</div><div class="buy"><p>$39.95</p><button type="submit" name="add">Add To Cart</button></div>`;
64
- const faq = '<script type="application/ld+json">{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[]}</script><script type="application/ld+json">{"@context":"https://schema.org","@type":"Product","name":"X"}</script>';
65
- const body = (h1, links = []) => `<body><main><h1>${h1}</h1>${'<p>Plain paragraph about the product, what it does and the price.</p>'.repeat(8)}
66
- <nav>${links.map((l) => `<a href="${l}">${l}</a>`).join('')}</nav></main></body></html>`;
67
- const urlset = (base, list) => `<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${list.map((p) => `<url><loc>${base}${p}</loc></url>`).join('')}</urlset>`;
68
-
69
- const hits = [];
70
- const server = createServer((req, res) => {
71
- hits.push(req.url);
72
- const base = `http://${req.headers.host}`;
73
- const xml = (s) => { res.writeHead(200, { 'content-type': 'application/xml' }); res.end(s); };
74
- const html = (s) => { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(s); };
75
- const u = req.url;
76
- if (u === '/robots.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(`User-agent: *\nDisallow: /cart\nSitemap: ${base}/sitemap.xml\n`); }
77
- if (u === '/sitemap.xml') return xml(`<?xml version="1.0"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${CHILDREN.map((c) => `<sitemap><loc>${base}/sitemap_${c.replace(/\?.*/, '')}.xml${c.includes('?') ? `?${c.split('?')[1]}` : ''}</loc></sitemap>`).join('')}</sitemapindex>`);
78
- if (u === '/sitemap_agentic_discovery.xml') return xml(urlset(base, ['/agents.md']));
79
- // Shopify отдаёт товары только с обоими параметрами: &amp; в адресе значит пустую карту.
80
- if (u.startsWith('/sitemap_products_1.xml')) return xml(urlset(base, u === '/sitemap_products_1.xml?from=1&to=2' ? [...BOOKS, ...MAIN] : []));
81
- if (u === '/sitemap_pages_1.xml') return xml(urlset(base, PAGES));
82
- if (u === '/sitemap_articles_1.xml') return xml(urlset(base, ARTICLES));
83
- if (u.startsWith('/sitemap_')) return xml(urlset(base, []));
84
- if (u === '/agents.md') { res.writeHead(200, { 'content-type': 'text/markdown' }); return res.end('# Agents\n'); }
85
- if (u.startsWith('/customer_authentication/')) return; // висит, как вход в аккаунт Shopify
86
- if (u === '/llms.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(`# Brain Store\n\nShop: ${base}.\nAgents: \`${base}/.well-known/ucp\`.\n- [Omega](${base}${MAIN[0]})\n- [Memory](${base}${MAIN[1]})\n- [Contact](${base}/pages/contact)\n- [Shop app](https://shop.app/brainstore)\n`); }
87
- if (u === '/.well-known/ucp') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{}'); }
88
- if (u === '/') return html(shopifyHead('Home', sameAs) + body('Brain Store', [MAIN[0], MAIN[0], MAIN[0], MAIN[1], MAIN[1], '/pages/contact', '/customer_authentication/redirect']));
89
- if (MAIN.includes(u)) return html(shopifyHead(u, faq) + `<body>${cartDrawer}${bigMenu}<main><h1>${u}</h1>${buyBox}${'<p>Plain paragraph about the product, what it does and how to take it.</p>'.repeat(40)}</main></body></html>`);
90
- if (PAGES.includes(u)) return html(shopifyHead(u) + `<body>${cartDrawer}` + body(u).slice('<body>'.length));
91
- if (BOOKS.includes(u) || u.startsWith('/blogs/')) return html(shopifyHead(u) + body(u));
92
- res.writeHead(404, { 'content-type': 'text/plain' }); res.end('no');
93
- });
94
- await new Promise((r) => server.listen(0, '127.0.0.1', r));
95
- // Бесплатная проверка не берёт 127.0.0.1 (это не публичный адрес), поэтому живая часть идёт через lvh.me.
96
- let dnsOk = true;
97
- try { dnsOk = (await lookup('lvh.me')).address === '127.0.0.1'; } catch { dnsOk = false; }
98
- if (!dnsOk) { console.log('ok пропуск живой части: lvh.me не указывает на 127.0.0.1 (нет сети)'); server.close(); process.exit(bad ? 1 : 0); }
99
- const base = `http://lvh.me:${server.address().port}`;
100
-
101
- try {
102
- const a = await collect(`${base}/`, { pages: 6, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
103
- const check = (id) => a.checks.find((c) => c.id === id) || {};
104
- const row = (label) => (a.overview.rows.find((r) => r[0] === label) || [])[1];
105
-
106
- // 1. Язык
107
- is('1. язык прочитан из многострочного <html>', a.meta.language, 'en');
108
-
109
- // 2. Карта сайта
110
- const total = BOOKS.length + MAIN.length + PAGES.length + ARTICLES.length + 1; // + agents.md
111
- is('2. прочитаны все десять файлов карты', check('sitemap').value, `${total} URL(s) in ${base}/sitemap.xml`);
112
- is('2. в обзоре полное число', row('Pages in sitemap'), String(total));
113
- ok('2. товары прочитаны по адресу с &, а не с &amp;', hits.includes('/sitemap_products_1.xml?from=1&to=2'));
114
-
115
- // 2. Если карта прочитана не вся, в обзоре это сказано, а не выдано за целое.
116
- const part = await collect(`${base}/`, { pages: 2, pageDelayMs: 0, sitemapMaxFiles: 3, log: () => {}, visibility: { ok: false, error: 'skipped in test' }, rendered: false });
117
- const partRow = (part.overview.rows.find((r) => r[0] === 'Pages in sitemap') || [])[1] || '';
118
- ok(`2. частичная цифра названа частичной (${partRow})`, /^at least \d+: 3 of 10 sitemap files were read/.test(partRow));
119
-
120
- // 3. Движок
121
- is('3. Shopify в обзоре', row('CMS / stack'), 'Shopify');
122
- is('3. Shopify в проверке', check('cms').value, 'Shopify (detected from the page code)');
123
- is('3. по-русски', localiseChecks([check('cms')], 'ru')[0].value, 'Shopify (определён по коду страницы)');
124
-
125
- // 4. Выборка
126
- const read = a.sample.filter((p) => p.title !== undefined).map((p) => new URL(p.url).pathname);
127
- ok(`4. основные товары с главной в выборке (${read.join(', ')})`, MAIN.every((p) => read.includes(p)));
128
- ok('4. чаще упомянутый на главной идёт раньше', read.indexOf(MAIN[0]) < read.indexOf(MAIN[1]));
129
- ok('4. книги не вытеснили товары', read.filter((p) => p.startsWith('/products/book-')).length <= 1);
130
- ok('4. agents.md не читается как страница', !read.includes('/agents.md'));
131
- is('4. FAQPage найдена', check('faq-schema').status, 'ok');
132
-
133
- // 6. Профили из sameAs со слешами \/
134
- is('6. профили прочитаны', check('trust-profiles').value, '3 profiles: Instagram, YouTube, Facebook');
135
-
136
- // 7, 10. llms.txt
137
- ok(`7. адрес с обратной кавычкой не мёртвый (${check('llms-health').value})`, check('llms-health').status === 'ok');
138
- // Точку после своего домена с портом URL не разберёт, поэтому сам случай brainmd.com. проверен выше на linksInText.
139
- is('10. свой домен с точкой не чужой хост', check('llms').value, '6 link(s), 1 to other hosts');
140
-
141
- // 8. Цена: из выборки, и пустая корзина ценой не считается
142
- const priced = a.sample.filter((p) => p.priceShown).map((p) => new URL(p.url).pathname);
143
- ok(`8. цена найдена на товарах и только на них (${priced.join(', ')})`, MAIN.every((p) => priced.includes(p)) && !priced.includes('/pages/contact'));
144
- const drafted = draftNarrative(a, { lang: 'en' });
145
- ok(`8. строка «цена видна» не пишет «цены нет» (${drafted.conversion.rows.find((r) => /price/i.test(r[0]))?.[1]})`, /^yes: a price is shown on 2 of \d+ pages/.test(drafted.conversion.rows.find((r) => /price/i.test(r[0]))?.[1] || ''));
146
-
147
- // 9. Призыв на первом экране: Add To Cart после огромного меню и галереи
148
- ok(`9. Add to cart на товаре засчитан (${check('conv-cta').value})`, /^2 of /.test(check('conv-cta').value) // два товара; ссылки главной стоят в <nav>, это не первый экран
149
- && a.sample.filter((p) => MAIN.some((m) => p.url.endsWith(m))).every((p) => p.ctaFirstScreen === true));
150
-
151
- // 5. Балл видимости
152
- const v = await checkVisibility(`${base}/`, { lang: 'en' });
153
- ok(`5. балл посчитан (${v.ok ? v.score : v.error})`, v.ok && Number.isFinite(v.score));
154
- const vRead = (v.sample || []).map((p) => new URL(p.url).pathname);
155
- ok(`5. в выборке нет agents.md и входа в аккаунт (${vRead.join(', ')})`, vRead.length >= 3 && !vRead.some((p) => p === '/agents.md' || p.startsWith('/customer_authentication')));
156
- } finally { server.close(); server.closeAllConnections?.(); }
157
-
158
- if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
159
- console.log('\nвсё сходится');
@@ -1,91 +0,0 @@
1
- #!/usr/bin/env node
2
- /** Обход карт сайта: ограничители и честность отчёта.
3
- *
4
- * 14.09.2026 бесплатная проверка не собрала отчёт по whitewill.ru. В его sitemap.xml 79 вложенных
5
- * карт, обход заходил в каждую по 15 секунд, и прогон умирал по таймауту, не дочитав до страниц.
6
- * Тесты поднимают такой же сайт локально, чтобы это не вернулось. */
7
- import { createServer } from 'node:http';
8
- import { __readSitemap } from './collect.mjs';
9
- import { localiseChecks } from './i18n.mjs';
10
-
11
- let bad = 0;
12
- const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
13
- const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
14
-
15
- const index = (hrefs) => `<?xml version="1.0"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${hrefs.map((h) => `<sitemap><loc>${h}</loc></sitemap>`).join('')}</sitemapindex>`;
16
- const urlset = (hrefs) => `<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${hrefs.map((h) => `<url><loc>${h}</loc></url>`).join('')}</urlset>`;
17
-
18
- /** Сервер отвечает медленно, как настоящий сайт на другом континенте. */
19
- const start = (delayMs, nested) => new Promise((resolve) => {
20
- const server = createServer(async (req, res) => {
21
- if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
22
- const base = `http://127.0.0.1:${server.address().port}`;
23
- res.setHeader('content-type', 'application/xml');
24
- if (req.url === '/sitemap.xml') return res.end(index(Array.from({ length: nested }, (_, i) => `${base}/map-${i}.xml`)));
25
- if (req.url === '/plain.xml') return res.end(urlset([`${base}/page`, `${base}/feed.xml`]));
26
- const i = (req.url.match(/map-(\d+)/) || [])[1] ?? '0';
27
- return res.end(urlset([`${base}/p-${i}-a`, `${base}/p-${i}-b`]));
28
- });
29
- server.listen(0, '127.0.0.1', () => resolve({ server, base: `http://127.0.0.1:${server.address().port}` }));
30
- });
31
-
32
- // 1. Семьдесят девять вложенных карт: читаем несколько файлов, а не все, и говорим об этом.
33
- {
34
- const { server, base } = await start(30, 79);
35
- const began = Date.now();
36
- const r = await __readSitemap(`${base}/sitemap.xml`, { maxFiles: 6, budgetMs: 45000 });
37
- const took = Date.now() - began;
38
- is('прочитано ровно столько файлов, сколько разрешено', r.filesRead, 6);
39
- is('остановились по числу файлов', r.stopped, 'files');
40
- is('нашли все вложенные карты', r.nestedFound, 79);
41
- ok('непрочитанные файлы посчитаны', r.pending === 74);
42
- ok('адресов хватает на выборку страниц', r.urls.length >= 10);
43
- ok(`уложились в разумное время (${took} мс)`, took < 5000);
44
- server.close();
45
- }
46
-
47
- // 2. Маленький сайт читается целиком, и тогда про ограничители в отчёте ни слова.
48
- {
49
- const { server, base } = await start(0, 2);
50
- const r = await __readSitemap(`${base}/sitemap.xml`);
51
- is('маленькая карта прочитана целиком', r.stopped, '');
52
- is('файлов прочитано: индекс и две карты', r.filesRead, 3);
53
- is('собраны все адреса', r.urls.length, 4);
54
- server.close();
55
- }
56
-
57
- // 3. Из обычного urlset вглубь не спускаемся, даже если адрес заканчивается на .xml.
58
- // Прежнее условие из-за приоритета операторов читало `A || (B && C && D)` и уходило в рекурсию.
59
- {
60
- const { server, base } = await start(0, 2);
61
- const r = await __readSitemap(`${base}/plain.xml`);
62
- is('из urlset рекурсии нет', r.filesRead, 1);
63
- is('ссылка на .xml осталась адресом страницы', r.urls.length, 2);
64
- server.close();
65
- }
66
-
67
- // 4. Общий бюджет времени соблюдается, даже если файлов мало, а сайт медленный.
68
- {
69
- const { server, base } = await start(200, 40);
70
- const began = Date.now();
71
- const r = await __readSitemap(`${base}/sitemap.xml`, { maxFiles: 100, budgetMs: 700 });
72
- const took = Date.now() - began;
73
- is('остановились по времени', r.stopped, 'time');
74
- ok(`бюджет времени соблюдён (${took} мс)`, took < 2500);
75
- server.close();
76
- }
77
-
78
- // 5. По-русски усечённый обход называется словами, а не «не отдаётся, код 1200».
79
- // Локализатор подбирает строку регулярным выражением, поэтому новая формулировка без своей
80
- // ветки молча проваливается в числовую. Ровно это уже случалось с нулями в отчёте.
81
- {
82
- const en = [{ id: 'sitemap', group: 'technical', label: 'XML sitemap', status: 'ok', value: '1200 URL(s) read from 6 of 79 sitemap file(s) in https://example.ru/sitemap.xml', comment: '' }];
83
- const ru = localiseChecks(en, 'ru')[0].value;
84
- ok(`усечение названо словами: "${ru}"`, /прочитано из 6 файлов карты, всего файлов 79/.test(ru));
85
- ok('и это не выдано за код ответа', !/не отдаётся/.test(ru));
86
- const whole = localiseChecks([{ ...en[0], value: '1200 URL(s) in https://example.ru/sitemap.xml' }], 'ru')[0].value;
87
- ok(`целая карта по-прежнему читается нормально: "${whole}"`, /в карте сайта/.test(whole));
88
- }
89
-
90
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
91
- console.log('\nвсе тесты обхода карт прошли');
@@ -1,173 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Не прочитали robots.txt, значит не знаем, а не «всё отлично».
4
- *
5
- * 15.09.2026 проверка ставила 25 из 25 и писала «Все 14 роботов ИИ допущены на сайт» сайту, чей
6
- * robots.txt отдал 403. Файла нет, значит запретов нет, значит отлично. Владелец получал высшую
7
- * оценку за файл, который мы не открывали. Незнание это не хорошая новость.
8
- *
9
- * Здесь держится вся цепочка: движок помечает область неизмеренной и переносит балл на сто по
10
- * остальным, проверка сходимости это понимает, отчёт печатает слово вместо числа и объясняет,
11
- * из чего сложился балл.
12
- *
13
- * Сервер поднимается на loopback, а зовётся по имени lvh.me: оно публично указывает на 127.0.0.1,
14
- * и так проверка проходит свой же запрет на частные адреса честно, без лазейки в коде продукта.
15
- */
16
- import { createServer } from 'node:http';
17
- import { readFileSync } from 'node:fs';
18
- import { lookup } from 'node:dns/promises';
19
- import { checkVisibility } from './visibility.mjs';
20
- import { verifyScores } from './collect.mjs';
21
- import { toHtml, overallSummary } from './render.mjs';
22
-
23
- let bad = 0;
24
- const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
25
- const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
26
-
27
- const PAGE = `<!doctype html><html lang="ru"><head><title>Пример</title><meta name="description" content="Описание"></head>
28
- <body><main><h1>Пример сайта</h1>${'<p>Обычный абзац текста про товар и цену, без цифр, чтобы было что цитировать. </p>'.repeat(40)}</main></body></html>`;
29
-
30
- const serve = ({ robots, status = null, llmsStatus = 404 }) => new Promise((resolve) => {
31
- const s = createServer((req, res) => {
32
- if (req.url.startsWith('/llms.txt')) { res.writeHead(llmsStatus, { 'content-type': 'text/html' }); res.end('<html>нет</html>'); return; }
33
- if (req.url.startsWith('/robots.txt')) {
34
- if (status) { res.writeHead(status, { 'content-type': 'text/html' }); res.end('<html>нет</html>'); return; }
35
- if (robots === null) { res.writeHead(403, { 'content-type': 'text/html' }); res.end('<html>denied</html>'); return; }
36
- res.writeHead(200, { 'content-type': 'text/plain' }); res.end(robots); return;
37
- }
38
- if (req.url === '/' ) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(PAGE); return; }
39
- res.writeHead(404, { 'content-type': 'text/plain' }); res.end('no');
40
- });
41
- s.listen(0, '127.0.0.1', () => resolve({ s, url: `http://lvh.me:${s.address().port}/` }));
42
- });
43
-
44
- let dnsOk = true;
45
- try { const a = await lookup('lvh.me'); dnsOk = a.address === '127.0.0.1'; } catch { dnsOk = false; }
46
-
47
- if (!dnsOk) {
48
- console.log('ПРОПУЩЕНО: lvh.me не указывает на 127.0.0.1 с этой машины, сквозная часть не прогнана');
49
- } else {
50
- // ---- robots.txt закрыт: область не измерена, балл по остальным
51
- const { s, url } = await serve({ robots: null });
52
- let v;
53
- try { v = await checkVisibility(url, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s.close(); }
54
- ok('проверка прошла (главная читается)', v.ok === true);
55
- const access = v.areas.find((a) => a.id === 'access');
56
- is('область доступа помечена неизмеренной', access.measured, false);
57
- is('и балла у неё нет, а не ноль и не 25', access.score, null);
58
- ok('первая находка говорит, что файл не прочитан', /не дал этой проверке прочитать robots\.txt/.test(access.findings[0].text));
59
- ok('и что это не хорошая новость', /не хорошая новость/.test(access.findings[0].text));
60
- ok('и не пишет «все роботы допущены»', !access.findings.some((f) => /допущены на сайт/.test(f.text)));
61
- const rest = v.areas.filter((a) => a.id !== 'access');
62
- const sum = rest.reduce((t, a) => t + a.score, 0);
63
- const max = rest.reduce((t, a) => t + a.max, 0);
64
- is('остальные области считаются из 75', max, 75);
65
- is('балл это доля измеренного, перенесённая на сто', v.score, Math.round((sum / max) * 100));
66
- ok('в исправлениях нет пункта про непрочитанный файл', !v.fixes.some((f) => f.id === 'robots-unreadable'));
67
-
68
- // ---- сходимость: проверка отчёта понимает неизмеренную область
69
- const problems = verifyScores({ checks: [], scores: {}, overall: { score: v.score, grade: v.grade, areas: v.areas } }).filter((p) => p.startsWith('overall:'));
70
- is('проверка сходимости не спорит с движком', problems, []);
71
-
72
- // ---- отчёт: слово вместо числа и честная подпись
73
- const fixture = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
74
- fixture.meta.lang = 'ru';
75
- fixture.overall = { score: v.score, grade: v.grade, areas: v.areas, source: 'visibility', basis: { pages: 1, sitemapRead: false, sitemapUnchecked: false } };
76
- const html = toHtml(fixture, null);
77
- // Полосы вместо таблицы с 17.09.2026: сторож тот же, неизмеренная область говорит словом.
78
- const bars = html.match(/<div class="areas-bars">[\s\S]*?<\/div>\s*(?=<h3|<div class="verdict")/)[0];
79
- ok('в областях стоит слово, а не null', /class="na">не измерялось</.test(bars) && !/null/.test(bars));
80
- const note = (html.match(/overall-note">([^<]*)</) || [])[1] || '';
81
- ok('подпись говорит, сколько областей измерено', /Из пяти областей ниже измерены 4/.test(note));
82
- ok('и из скольких очков', new RegExp(`${sum} из 75`).test(note));
83
- ok('и что неизмеренное не в минус', /не зачтена ни в плюс, ни в минус/.test(note));
84
- ok('старой фразы «пять областей дают в сумме ровно» нет', !/дают в сумме ровно/.test(note));
85
-
86
- fixture.meta.lang = 'en';
87
- const en = (toHtml(fixture, null).match(/overall-note">([^<]*)</) || [])[1] || '';
88
- ok('английская подпись тоже честная', /4 could be measured/.test(en) && /not counted for or against/.test(en));
89
-
90
- // ---- письмо берёт тот же блок: null остаётся null, слово едет рядом
91
- const head = overallSummary(fixture.overall, { lang: 'ru' });
92
- is('в блоке для письма область без балла', head.areas.find((a) => a.id === 'access').score, null);
93
- is('и слово для неё передано', head.notMeasured, 'не измерялось');
94
-
95
- // ---- контроль: с прочитанным robots.txt всё как раньше
96
- const { s: s2, url: url2 } = await serve({ robots: 'User-agent: *\nDisallow: /admin/\n' });
97
- let v2;
98
- try { v2 = await checkVisibility(url2, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s2.close(); }
99
- const access2 = v2.areas.find((a) => a.id === 'access');
100
- is('с прочитанным файлом область измерена', access2.measured, true);
101
- is('и оценена полностью', access2.score, 25);
102
- is('и балл это прямая сумма', v2.score, v2.areas.reduce((t, a) => t + a.score, 0));
103
- ok('подпись в отчёте прежняя', /дают в сумме ровно/.test((toHtml({ ...fixture, meta: { ...fixture.meta, lang: 'ru' }, overall: { ...fixture.overall, score: v2.score, areas: v2.areas } }, null).match(/overall-note">([^<]*)</) || [])[1] || ''));
104
- }
105
-
106
- // ---- файла нет это ОТВЕТ, а не незнание
107
- //
108
- // Первая версия правки считала неизмеренным любой неуспех, и сайт без robots.txt терял 25
109
- // заслуженных баллов: по стандарту отсутствие файла означает, что не запрещено ничего.
110
- // Поймано на живом example.com сразу после выкладки: балл упал с 30 до 7.
111
- for (const code of [404, 410]) {
112
- const { s: s3, url: url3 } = await serve({ status: code });
113
- let v3;
114
- try { v3 = await checkVisibility(url3, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s3.close(); }
115
- const a3 = v3.areas.find((a) => a.id === 'access');
116
- is(`robots.txt ${code}: область измерена`, a3.measured, true);
117
- is(`robots.txt ${code}: и оценена полностью`, a3.score, 25);
118
- ok(`robots.txt ${code}: сказано, что файла нет`, /Файла robots\.txt на сайте нет/.test(a3.findings[0].text));
119
- ok(`robots.txt ${code}: и что это значит «не запрещено ничего»`, /не запрещено ничего/.test(a3.findings[0].text));
120
- ok(`robots.txt ${code}: не назвали это непрочитанным`, !a3.findings.some((f) => f.id === 'robots-unreadable'));
121
- is(`robots.txt ${code}: балл это прямая сумма`, v3.score, v3.areas.reduce((t, a) => t + a.score, 0));
122
- }
123
-
124
- // ---- а отказ и сбой это по-прежнему незнание
125
- for (const code of [401, 403, 429, 500]) {
126
- const { s: s4, url: url4 } = await serve({ status: code });
127
- let v4;
128
- try { v4 = await checkVisibility(url4, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s4.close(); }
129
- const a4 = v4.areas.find((a) => a.id === 'access');
130
- is(`robots.txt ${code}: область не измерена`, a4.measured, false);
131
- ok(`robots.txt ${code}: код назван в тексте`, new RegExp(`код ${code}`).test(a4.findings[0].text));
132
- }
133
-
134
- // ---- llms.txt: та же граница между «нет файла» и «не дали файл»
135
- //
136
- // Сайт за защитой отдавал 403 на llms.txt, и мы писали владельцу «у вас нет карты для агентов»
137
- // с нулём баллов. Файл мог быть, мы его просто не получили.
138
- {
139
- const cases = [[404, 'missing'], [410, 'missing'], [403, 'unknown'], [429, 'unknown'], [500, 'unknown']];
140
- for (const [code, kind] of cases) {
141
- const { s: s5, url: url5 } = await serve({ robots: 'User-agent: *\nDisallow: /admin/\n', llmsStatus: code });
142
- let v5;
143
- try { v5 = await checkVisibility(url5, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s5.close(); }
144
- const a5 = v5.areas.find((a) => a.id === 'index');
145
- if (kind === 'missing') {
146
- is(`llms.txt ${code}: область измерена`, a5.measured, true);
147
- is(`llms.txt ${code}: и это честный ноль`, a5.score, 0);
148
- ok(`llms.txt ${code}: находка называет отсутствие`, a5.findings.some((f) => f.id === 'llms-missing'));
149
- } else {
150
- is(`llms.txt ${code}: область не измерена`, a5.measured, false);
151
- is(`llms.txt ${code}: балла нет, а не ноль`, a5.score, null);
152
- ok(`llms.txt ${code}: не пишем «у вас нет файла»`, !a5.findings.some((f) => f.id === 'llms-missing'));
153
- ok(`llms.txt ${code}: сказано, что не отдали`, /не дал этой проверке прочитать llms\.txt/.test(a5.findings[0].text));
154
- is(`llms.txt ${code}: знаменатель без этой области`, v5.score, Math.round((v5.areas.filter((a) => a.measured !== false).reduce((t, a) => t + a.score, 0) / v5.areas.filter((a) => a.measured !== false).reduce((t, a) => t + a.max, 0)) * 100));
155
- }
156
- }
157
- }
158
-
159
- // ---- две неизмеренные области сразу: знаменатель считается по оставшимся трём
160
- {
161
- const { s: s6, url: url6 } = await serve({ robots: null, llmsStatus: 403 });
162
- let v6;
163
- try { v6 = await checkVisibility(url6, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s6.close(); }
164
- const un = v6.areas.filter((a) => a.measured === false);
165
- is('неизмеренных областей две', un.length, 2);
166
- const meas = v6.areas.filter((a) => a.measured !== false);
167
- is('знаменатель 60', meas.reduce((t, a) => t + a.max, 0), 60);
168
- is('балл по оставшимся', v6.score, Math.round((meas.reduce((t, a) => t + a.score, 0) / 60) * 100));
169
- ok('ни одна из них не получила ноль', un.every((a) => a.score === null));
170
- }
171
-
172
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
173
- console.log('\nнепрочитанное не считается ни в плюс, ни в минус, а отсутствующее это ответ');