@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,117 +0,0 @@
1
- #!/usr/bin/env node
2
- /** Черновик текста аудита: заполняет всё, ничего не выдумывает, читается человеком. */
3
- import { draftNarrative, stillEmpty } from './narrative.mjs';
4
-
5
- let bad = 0;
6
- const is = (n, a, b) => { const ok = JSON.stringify(a) === JSON.stringify(b); if (!ok) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
7
- const ok = (n, c) => is(n, Boolean(c), true);
8
-
9
- const audit = (over = {}) => ({
10
- meta: { host: 'x.ru', site: 'https://x.ru', collectedAt: '2026-09-11T00:00:00.000Z', lang: 'en', language: '' },
11
- client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: '2026-09-11', preparedBy: 'OperStack' },
12
- scores: { 'SEO, technical': 9, 'SEO, content and structure': 4, 'AEO, answers and snippets': 6, 'GEO, visibility in AI systems': 8, 'Off-page and trust': null, 'Conversion and UX': 5 },
13
- summary: { lead: '{{a}}', verdict: '{{b}}', priorities: ['{{1}}', '{{2}}', '{{3}}'] },
14
- overview: { rows: [['CMS / stack', 'Astro'], ['Pages in sitemap', '40'], ['Language', '{{language}}'], ['What is sold', '{{products}}']], note: '{{note}}' },
15
- content: { strengths: ['{{s}}'], weaknesses: ['{{w}}'], gaps: ['{{g}}'] },
16
- aeo: { works: ['{{x}}'], blocks: ['{{y}}'], recommendation: '{{r}}' },
17
- geo: { rows: [['Entity clarity', '{{s}}', '{{d}}'], ['Third-party mentions', '{{s}}', '{{d}}'], ['Reviews and PR', '{{s}}', '{{d}}']], callout: '{{c}}' },
18
- offpage: { listed: ['{{l}}'], note: '{{n}}' },
19
- conversion: { rows: [['Contact form', '{{s}}'], ['Price visible', '{{s}}'], ['Messengers', '{{s}}']] },
20
- roadmap: [
21
- { badge: 'Week 1', title: 'Critical', items: ['Fix: Image alt text', '{{...}}'] },
22
- { badge: 'Weeks 2 to 4', title: 'Foundation', items: ['{{...}}'] },
23
- { badge: 'Months 2 to 3', title: 'Growth', items: ['{{...}}'] },
24
- ],
25
- closing: '{{close}}',
26
- sample: [{ url: 'https://x.ru/', title: 'Дом', description: 'Мы продаём станки', words: 900, answerFirst: false }],
27
- checks: [
28
- { id: 'alt', group: 'onpage', label: 'Image alt text', status: 'bad', value: '3 of 10 images carry alt text (30%) across 5 sampled page(s)' },
29
- { id: 'thin', group: 'content', label: 'Thin pages in sample', status: 'warn', value: '4 of 20 pages under 300 words' },
30
- { id: 'answer-first', group: 'aeo', label: 'Answer-first opening paragraph', status: 'bad', value: '2 of 20 sampled page(s) open with a figure' },
31
- { id: 'sources', group: 'aeo', label: 'Sources named in the text', status: 'ok', value: '18 of 20 name a source' },
32
- { id: 'dates', group: 'content', label: 'Publication dates exposed', status: 'ok', value: '19 of 20 expose dates' },
33
- { id: 'conv-cta', group: 'conversion', label: 'A call to action in the first screen', status: 'bad', value: '1 of 20 put it in the first 30%' },
34
- { id: 'conv-messenger', group: 'conversion', label: 'Messenger link', status: 'ok', value: 'telegram found' },
35
- { id: 'trust-entity', group: 'trust', label: 'Who the business is', status: 'ok', value: 'city and country in the structured data' },
36
- { id: 'ai-search-access', group: 'geo', label: 'AI search fetchers allowed', status: 'ok', value: 'every AI search fetcher may read the site' },
37
- ],
38
- ...over,
39
- });
40
-
41
- const d = draftNarrative(audit(), { lang: 'ru' });
42
- is('не осталось ни одной дыры', stillEmpty(d), []);
43
- ok('имя клиента это адрес сайта, а не выдумка', d.client.name === 'x.ru');
44
- ok('предмет взят с главной и помечен как прочитанный', d.client.subject.includes('Мы продаём станки') && /это то, что о вас прочитает/i.test(d.client.subject));
45
- // Клиентский текст не обращается к аналитику: это товар, а не черновик на правку.
46
- ok('в клиентском тексте нет обращений к аналитику', !/подтвердите|please confirm/i.test(JSON.stringify(d)));
47
-
48
- // Русский аудит, русский отчёт: названия областей тоже русские. Раньше перевод срабатывал
49
- // только для аудита, собранного по-английски, и в русский текст уезжали английские названия.
50
- {
51
- const ruAudit = audit();
52
- ruAudit.meta.lang = 'ru';
53
- const dru = draftNarrative(ruAudit, { lang: 'ru' });
54
- ok('в русском отчёте по русскому аудиту нет английских названий областей',
55
- !/SEO, technical|content and structure|answers and snippets|visibility in AI systems|Off-page and trust|Conversion and UX/.test(dru.summary.lead));
56
- }
57
-
58
- // Ничего не выдумываем: там, где не мерили, так и написано
59
- ok('упоминания на чужих сайтах помечены как неизмеренные', d.geo.rows[1][1] === 'не измеряли');
60
- ok('и объяснено почему', /за деньги/.test(d.geo.rows[1][2]));
61
- ok('в разделе ссылок сказано, что данные платные', /не покупаем данные/.test(d.offpage.note));
62
- ok('язык не выдуман, если его нет в коде', /не указан/.test(d.overview.rows[2][1]));
63
- // Число в начале резюме это выборка, а не размер сайта: «сайт из 19 страниц» о магазине на
64
- // сотни товаров неправда (brainmd.com, 12.10.2026).
65
- ok('резюме говорит, сколько прочитано, а не сколько страниц у сайта', /^Мы прочитали \d+ страниц/.test(d.summary.lead) && !/сайт из/.test(d.summary.lead));
66
- {
67
- const en = draftNarrative(audit(), { lang: 'en' });
68
- ok('и по-английски тоже', /^We read \d+ pages? of /.test(en.summary.lead) && !/is a site of/.test(en.summary.lead));
69
- const withLang = audit(); withLang.meta.language = 'en';
70
- is('язык из кода страницы попадает в обзор словом', draftNarrative(withLang, { lang: 'en' }).overview.rows[2][1], 'English');
71
- }
72
-
73
- // Числа сходятся между собой
74
- const openN = 4; const mech = 2; // alt, conv-cta механические; thin и answer-first это тексты
75
- ok('в выводе число открытых проверок верное', d.summary.verdict.startsWith(`${openN} `) && /не полностью/.test(d.summary.verdict));
76
- ok('и механических не больше, чем всех', d.summary.verdict.includes(`${mech} правятся`) || d.summary.verdict.includes(`${mech} правится`));
77
-
78
- // Приоритеты это действие, а не название проверки
79
- ok('приоритет говорит, что делать', /Проставить подписи/.test(d.summary.priorities[0]));
80
- ok('и показывает, как сейчас', /Сейчас:/.test(d.summary.priorities[0]));
81
- ok('работа с текстами названа работой, а не отговоркой', d.summary.priorities.some((p) => /Наполнить короткие страницы|прямой ответ/.test(p)));
82
-
83
- // Русский отчёт по английскому аудиту не должен содержать английских названий проверок
84
- const ruText = JSON.stringify([d.summary, d.content, d.aeo, d.roadmap, d.closing]);
85
- ok('в русском черновике нет английских названий проверок', !/Image alt text|Thin pages|call to action/.test(ruText));
86
- ok('области названы по-русски', /доступ и адреса|техническая часть|конверсия/i.test(d.summary.lead));
87
-
88
- // Дорожная карта: уже написанные строки переведены, пустые заполнены
89
- ok('готовая строка карты переведена', d.roadmap[0].items[0].includes('подписи'));
90
- ok('пустая строка карты заполнена делом', !d.roadmap[1].items[0].includes('{{'));
91
-
92
- // Английский отчёт остаётся английским
93
- const e = draftNarrative(audit(), { lang: 'en' });
94
- is('по-английски тоже без дыр', stillEmpty(e), []);
95
- ok('английский черновик без кириллицы', !/[А-Яа-яЁё]/.test(JSON.stringify([e.summary, e.aeo, e.closing])));
96
- ok('и приоритет тоже действие', /Write alt text/.test(e.summary.priorities[0]));
97
-
98
- // Здоровый сайт: вывод не пугает, а говорит правду
99
- const clean = draftNarrative(audit({ checks: [
100
- { id: 'alt', group: 'onpage', label: 'Image alt text', status: 'ok', value: 'all images carry alt text' },
101
- { id: 'ai-search-access', group: 'geo', label: 'AI search fetchers allowed', status: 'ok', value: 'allowed' },
102
- ] }), { lang: 'ru' });
103
- ok('на чистом сайте вывод говорит, что чинить нечего', /критичных поломок нет/.test(clean.summary.verdict));
104
- ok('и заключение не выдумывает проблем', /нечего/.test(clean.closing));
105
-
106
- // Ничего уже написанного человеком не затирается
107
- const edited = audit();
108
- edited.summary.lead = 'Текст, который написал аналитик.';
109
- const keep = draftNarrative(edited, { lang: 'ru' });
110
- is('правку аналитика черновик не трогает', keep.summary.lead, 'Текст, который написал аналитик.');
111
-
112
- // Заблокированные фетчеры это прямой приговор, а не намёк
113
- const blocked = draftNarrative(audit({ checks: [{ id: 'ai-search-access', group: 'geo', label: 'AI search fetchers allowed', status: 'bad', value: '3 blocked' }] }), { lang: 'ru' });
114
- ok('если роботы ИИ закрыты, об этом сказано прямо', /невозможно физически/.test(blocked.geo.callout));
115
-
116
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
117
- console.log('\nвсе тесты черновика прошли');
@@ -1,236 +0,0 @@
1
- #!/usr/bin/env node
2
- /** Один балл на странице, в письме и в отчёте.
3
- *
4
- * 14.09.2026 человек увидел 46 на странице проверки, 61 в письме и шесть областей из шестидесяти
5
- * в приложенном PDF. Три числа про один сайт за один день. Считали их разные движки с разными
6
- * рамками, и единственный вывод, который делает читатель, это что цифры выдуманы.
7
- *
8
- * Теперь движок один и живёт в этом пакете, а параметры зафиксированы в VISIBILITY_DEFAULTS.
9
- * Эти тесты держат три вещи: балл сходится сам с собой, сборщик берёт его у того же движка, и
10
- * отчёт печатает именно его, на обоих языках. */
11
- import { readFileSync } from 'node:fs';
12
- import { createServer } from 'node:http';
13
- import { collect, computeOverall, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
14
- import { checkVisibility, VISIBILITY_DEFAULTS } from './visibility.mjs';
15
- import { toHtml, overallSummary } from './render.mjs';
16
-
17
- let bad = 0;
18
- const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
19
- const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
20
- const c = (group, status, i = 0) => ({ id: `${group}-${status}-${i}`, group, status, label: group, value: 'x' });
21
-
22
- // ---- балл по проверкам отчёта: отдельное измерение, оно обязано пересчитываться из checks
23
- is('веса шести областей дают сто', SCORE_AREAS.reduce((s, a) => s + a.weight, 0), 100);
24
- const every = (status) => SCORE_AREAS.flatMap((a) => a.groups.map((g) => c(g, status)));
25
- is('все пройдены: сто', computeOverall(every('ok')).score, 100);
26
- is('все провалены: ноль', computeOverall(every('bad')).score, 0);
27
- is('пустой набор не даёт нуля', computeOverall([]).score, null);
28
- {
29
- const o = computeOverall([c('technical', 'ok'), c('geo', 'ok')]);
30
- is('неизмеренное выпадает из знаменателя, а не становится нулём', o.score, 100);
31
- is('в знаменателе только измеренные веса', o.weighed, 40);
32
- }
33
-
34
- // ---- параметры измерения зафиксированы: их расхождение и есть расхождение чисел
35
- is('бюджет времени один на всех', VISIBILITY_DEFAULTS.budgetMs, 8500);
36
- // Девять плюс главная это десять. Число стоит на витрине словами «другие проверяют одну
37
- // страницу, мы проверяем десять», поэтому оно обязано совпадать с тем, что движок правда читает.
38
- is('глубина выборки одна на всех', VISIBILITY_DEFAULTS.samplePages, 9);
39
- ok('параметры нельзя поменять на ходу', Object.isFrozen(VISIBILITY_DEFAULTS));
40
-
41
- // ---- балл видимости сходится сам с собой
42
- {
43
- const audit = { checks: [], overall: { score: 61, areas: [{ score: 20, max: 25 }, { score: 10, max: 15 }, { score: 12, max: 20 }, { score: 14, max: 25 }, { score: 5, max: 15 }] } };
44
- is('честный заголовок проверку проходит', verifyScores(audit), []);
45
- ok('подделанный заголовок ловится', verifyScores({ ...audit, overall: { ...audit.overall, score: 70 } }).some((p) => p.startsWith('overall')));
46
- ok('шкала не из ста ловится', verifyScores({ checks: [], overall: { score: 20, areas: [{ score: 20, max: 30 }] } }).some((p) => /out of 30/.test(p)));
47
- }
48
- {
49
- const checks = [c('technical', 'ok'), c('geo', 'bad')];
50
- const audit = { checks, scores: computeScores(checks).scores, reportScore: computeOverall(checks) };
51
- is('балл по проверкам отчёта сходится с checks', verifyScores(audit), []);
52
- ok('подделанный балл по проверкам ловится', verifyScores({ ...audit, reportScore: { score: 99 } }).some((p) => p.startsWith('reportScore')));
53
- }
54
-
55
- /*
56
- * Движок намеренно отказывается мерить приватные адреса: публичная проверка не должна ходить
57
- * по чужой локальной сети. Значит локальным сайтом «одно число» не докажешь, и это правильно.
58
- * Здесь проверяется, что в таком случае сборщик не выдумывает балл, а честно отдаёт «не измерено»,
59
- * и что отчёт от этого не ломается. Совпадение чисел на живом сайте проверяется отдельно,
60
- * командой `npm run verify:live`, потому что для этого нужна сеть.
61
- */
62
- {
63
- const server = createServer((req, res) => {
64
- res.setHeader('content-type', 'text/html');
65
- res.end('<!doctype html><html><head><title>Local</title></head><body><h1>Local</h1><p>Text.</p></body></html>');
66
- });
67
- await new Promise((r) => server.listen(0, '127.0.0.1', r));
68
- const base = `http://127.0.0.1:${server.address().port}/`;
69
- const direct = await checkVisibility(base, { ...VISIBILITY_DEFAULTS, lang: 'en' });
70
- const audit = await collect(base, { pages: 2, log: () => {} });
71
- server.close();
72
-
73
- ok('приватный адрес движок мерить отказывается', !direct.ok);
74
- is('и сборщик не выдумывает за него балл', audit.overall.score, null);
75
- is('а называет это неизмеренным', audit.overall.grade, 'not measured');
76
- is('и говорит, откуда балл должен был прийти', audit.overall.source, 'visibility');
77
- ok('причина отказа сохранена, а не потеряна', typeof audit.overall.error === 'string' && audit.overall.error.length > 0);
78
- ok('балл по проверкам отчёта при этом посчитан', typeof audit.reportScore.score === 'number');
79
- is('и отчёт по-прежнему сходится сам с собой', verifyScores(audit), []);
80
-
81
- for (const lang of ['en', 'ru']) {
82
- audit.meta.lang = lang;
83
- const html = toHtml(audit, null);
84
- // Искать надо напечатанный элемент, а не название стиля: `.overall-num` есть в CSS всегда.
85
- ok(`${lang}: без балла заголовок не печатается вовсе`, !/class="ring ring-/.test(html));
86
- ok(`${lang}: шесть областей отчёта на месте`, /second-measure/.test(html));
87
- }
88
- }
89
-
90
- // Тот же отчёт, но с настоящим баллом: печать заголовка и обе подписи.
91
- {
92
- const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
93
- audit.overall = { score: 61, grade: 'B', source: 'visibility', areas: [
94
- { id: 'access', label: 'Access for AI crawlers', score: 20, max: 25 },
95
- { id: 'index', label: 'Agent index', score: 10, max: 15 },
96
- { id: 'entity', label: 'Entity and structure', score: 12, max: 20 },
97
- { id: 'content', label: 'Answer-first content', score: 14, max: 25 },
98
- { id: 'trust', label: 'Freshness and sources', score: 5, max: 15 }] };
99
- is('заголовок сходится со своими областями', verifyScores(audit).filter((p) => p.startsWith('overall')), []);
100
- for (const [lang, grade] of [['en', /Strong|Workable|Weak|Poor|Critical/], ['ru', /Сильно|Рабочее состояние|Слабо|Плохо|Критично/]]) {
101
- audit.meta.lang = lang;
102
- const html = toHtml(audit, null);
103
- is(`${lang}: в отчёте стоит сохранённый балл`, Number((html.match(/class="ring-num"[^>]*>(\d+)</) || [])[1]), 61);
104
- ok(`${lang}: оценка словом на своём языке`, grade.test((html.match(/overall-grade">([^<]*)</) || [])[1] || ''));
105
- ok(`${lang}: сказано, что это то же измерение, что на странице`, /ai[-\u2011]visibility/.test((html.match(/overall-note">([^<]*)</) || [])[1] || ''));
106
- ok(`${lang}: пять областей напечатаны рядом с баллом`, /class="areas-bars"/.test(html) && (html.match(/class="ar ar-/g) || []).length === 5);
107
- ok(`${lang}: балл стоит и пилюлей с буквой`, /class="pill pill-(good|fair|poor)"/.test(html));
108
- const foot = (html.match(/scorecard-foot">([^<]*)</) || [])[1] || '';
109
- ok(`${lang}: сказано, что шесть областей не складываются в заголовок`, lang === 'en' ? /not a breakdown/.test(foot) : /\u043d\u0435 \u0440\u0430\u0437\u0431\u0438\u0432\u043a\u0430/.test(foot));
110
- }
111
- }
112
-
113
- /*
114
- * Балл, уже измеренный на странице, приезжает в отчёт готовым и не перемеряется.
115
- *
116
- * Живая сверка 14.09.2026: whitewill.ru дал 47 и 44 в двух прогонах подряд, habr.com в одном
117
- * прогоне вернул отказ. Тот же код, разная удача сети: бюджет 8,5 секунды, и карта сайта то
118
- * успевает прочитаться, то нет. Покупателю всё равно, что оба числа честные, он видит два разных.
119
- * Поэтому в воронке измерение происходит один раз.
120
- */
121
- {
122
- const server = createServer((req, res) => {
123
- res.setHeader('content-type', 'text/html');
124
- res.end('<!doctype html><html><head><title>Local</title></head><body><h1>Local</h1><p>Text.</p></body></html>');
125
- });
126
- await new Promise((r) => server.listen(0, '127.0.0.1', r));
127
- const base = `http://127.0.0.1:${server.address().port}/`;
128
-
129
- const fromPage = { ok: true, score: 63, grade: 'B', checkedAt: '2026-09-14T20:00:00.000Z', ms: 4200, areas: [
130
- { id: 'access', label: 'Access for AI crawlers', score: 25, max: 25 },
131
- { id: 'index', label: 'Agent index', score: 10, max: 15 },
132
- { id: 'entity', label: 'Entity and structure', score: 12, max: 20 },
133
- { id: 'content', label: 'Answer-first content', score: 11, max: 25 },
134
- { id: 'trust', label: 'Freshness and sources', score: 5, max: 15 }] };
135
-
136
- const audit = await collect(base, { pages: 2, log: () => {}, visibility: fromPage });
137
- server.close();
138
-
139
- is('готовый балл берётся как есть', audit.overall.score, 63);
140
- is('и оценка буквой тоже', audit.overall.grade, 'B');
141
- is('видно, что балл переиспользован, а не измерен заново', audit.overall.source, 'visibility:reused');
142
- is('сохранено, когда его измерили', audit.overall.measuredAt, '2026-09-14T20:00:00.000Z');
143
- is('пять областей приехали целиком', audit.overall.areas.length, 5);
144
- is('и складываются в заголовок', audit.overall.areas.reduce((a, x) => a + x.score, 0), 63);
145
- is('отчёт сходится сам с собой', verifyScores(audit), []);
146
- // Локальный адрес движок бы мерить отказался, а балл всё равно есть: значит второго измерения не было.
147
- ok('второго измерения не случилось', audit.overall.score === 63 && !audit.overall.error);
148
-
149
- audit.meta.lang = 'ru';
150
- is('и отчёт печатает именно его', Number((toHtml(audit, null).match(/class="ring-num"[^>]*>(\d+)</) || [])[1]), 63);
151
- }
152
-
153
- /*
154
- * Подпись под баллом обязана объяснить человеку три вещи, иначе он прочитает разницу в пару
155
- * пунктов как ошибку: откуда взялось число, что успели прочитать, и почему повтор может дать
156
- * иначе. Это критерий приёмки, а не украшение: человек, прочитавший сначала страницу, а потом
157
- * отчёт, не должен удивиться.
158
- */
159
- {
160
- const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
161
- const areas = [{ id: 'access', label: 'Access', score: 20, max: 25 }, { id: 'index', label: 'Index', score: 10, max: 15 },
162
- { id: 'entity', label: 'Entity', score: 12, max: 20 }, { id: 'content', label: 'Content', score: 14, max: 25 }, { id: 'trust', label: 'Trust', score: 5, max: 15 }];
163
- const note = (a) => ((toHtml(a, null).match(/overall-note">([^<]*)</) || [])[1] || '');
164
-
165
- for (const lang of ['en', 'ru']) {
166
- audit.meta.lang = lang;
167
-
168
- audit.overall = { score: 61, grade: 'B', areas, source: 'visibility:reused', measuredAt: '2026-09-14T20:00:00.000Z', basis: { pages: 4, sitemapRead: true, sitemapUnchecked: false } };
169
- const reused = note(audit);
170
- ok(`${lang}: сказано, что число уже видели на проверке`, lang === 'en' ? /already saw on the free check/.test(reused) : /уже видели в бесплатной проверке/.test(reused));
171
- ok(`${lang}: сказано, что здесь не меряли заново`, lang === 'en' ? /not measured again here/.test(reused) : /не меряется заново/.test(reused));
172
- ok(`${lang}: названа дата измерения`, /2026-09-14/.test(reused));
173
- ok(`${lang}: сказано, сколько страниц прочитано`, /4/.test(reused));
174
- ok(`${lang}: предупреждает про разброс в пункт-другой`, lang === 'en' ? /a point or two/.test(reused) : /на пункт-другой/.test(reused));
175
-
176
- audit.overall = { score: 61, grade: 'B', areas, source: 'visibility', basis: { pages: 1, sitemapRead: false, sitemapUnchecked: true } };
177
- const fresh = note(audit);
178
- ok(`${lang}: при собственном измерении сказано именно это`, lang === 'en' ? /Measured here by the same code/.test(fresh) : /Измерено здесь тем же кодом/.test(fresh));
179
- ok(`${lang}: непрочитанная карта сайта названа, а не спрятана`, lang === 'en' ? /sitemap did not answer in time/.test(fresh) : /карта сайта не ответила вовремя/.test(fresh));
180
- ok(`${lang}: и сказано, что она не зачтена в минус`, lang === 'en' ? /not counted against the score/.test(fresh) : /не зачтено ни в плюс, ни в минус/.test(fresh));
181
- ok(`${lang}: в подписи нет чужого языка`, lang === 'en' ? !/[А-Яа-яЁё]/.test(fresh) : !/Measured|sitemap did/.test(fresh));
182
- }
183
-
184
- // Русское склонение в подписи: человек это читает.
185
- audit.meta.lang = 'ru';
186
- for (const [n, word] of [[1, 'страница'], [2, 'страницы'], [5, 'страниц'], [11, 'страниц'], [21, 'страница']]) {
187
- audit.overall = { score: 61, grade: 'B', areas, source: 'visibility', basis: { pages: n, sitemapRead: true, sitemapUnchecked: false } };
188
- ok(`ru: ${n} ${word}`, new RegExp(`Прочитано ${n} ${word} сайта`).test(note(audit)));
189
- }
190
- }
191
-
192
- // ---- один блок «балл и области» на отчёт и на письмо
193
- //
194
- // Письмо собирало свою формулировку и свою цифру, отчёт свою. Разошлись. Теперь обе стороны
195
- // зовут overallSummary, и разойтись нечему: текст ровно один и живёт в пакете.
196
- {
197
- const areas = [{ id: 'access', label: 'Can AI crawlers read it', score: 20, max: 25 },
198
- { id: 'index', label: 'Is there a map for agents (llms.txt)', score: 5, max: 15 },
199
- { id: 'entity', label: 'Is the entity clear (schema)', score: 12, max: 20 },
200
- { id: 'content', label: 'Is there something to quote', score: 18, max: 25 },
201
- { id: 'trust', label: 'Can it be dated and trusted', score: 8, max: 15 }];
202
- const overall = { score: 63, grade: 'B', areas, source: 'visibility:reused', measuredAt: '2026-09-14T20:00:00.000Z', basis: { pages: 4, sitemapRead: true, sitemapUnchecked: false } };
203
-
204
- const ru = overallSummary(overall, { lang: 'ru' });
205
- const en = overallSummary(overall, { lang: 'en' });
206
- is('заголовок письма это заголовок отчёта', ru.score, 63);
207
- is('сумма пяти областей даёт заголовок', ru.areas.reduce((a, x) => a + x.score, 0), ru.score);
208
- ok('оценка словом переведена', ru.grade === 'Рабочее состояние' && en.grade === 'Workable');
209
- const fixture = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
210
- fixture.meta.lang = 'ru'; fixture.overall = overall;
211
- ok('подпись та же, что печатает отчёт', ru.note === (toHtml(fixture, null).match(/overall-note">([^<]*)</) || [])[1]);
212
-
213
- // Замер мог пройти на английской странице, а отчёт уходит русскому покупателю: язык отчёта главнее.
214
- ok('названия областей на языке отчёта, а не замера', ru.areas[0].label === 'Могут ли роботы ИИ прочитать сайт');
215
- ok('и в английском они английские', en.areas[0].label === 'Can AI crawlers read it');
216
- ok('в русском блоке нет английских названий', !ru.areas.some((x) => /[A-Za-z]{4}/.test(x.label.replace('llms.txt', ''))));
217
-
218
- // Шесть областей отчёта это второе измерение, и подпись обязана это говорить в обоих языках.
219
- ok('подзаголовок второго измерения есть', /Второе, отдельное измерение/.test(ru.secondMeasure) && /second, separate measurement/.test(en.secondMeasure));
220
- ok('и сказано, что складывать их с заголовком не надо', /в сумме его не дают/.test(ru.secondMeasureFoot) && /will not add up to it/.test(en.secondMeasureFoot));
221
- ok('в подписи второго измерения нет разметки', !/</.test(ru.secondMeasureFoot) && !/</.test(en.secondMeasureFoot));
222
-
223
- // Балла может не быть: движок не мерит закрытые адреса. Тогда письмо не называет цифру вовсе.
224
- is('без балла блока нет', overallSummary({ score: null, grade: 'not measured', areas: [] }, { lang: 'ru' }), null);
225
- is('и на пустом объекте тоже', overallSummary(null, { lang: 'en' }), null);
226
- }
227
-
228
- // ---- движок в пакете это та же программа, что стоит на сайтах
229
- {
230
- const here = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');
231
- ok('движок несёт свои параметры', /VISIBILITY_DEFAULTS/.test(here));
232
- ok('и не тянет за собой зависимостей', !/^import /m.test(here.replace(/VISIBILITY_DEFAULTS/g, '')));
233
- }
234
-
235
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
236
- console.log('\nвсе тесты общего балла прошли');
@@ -1,114 +0,0 @@
1
- /**
2
- * 20 подсаженных ошибок (задание 14.10.2026, «второй шаг проверки фактов»): в отчёты пяти эталонов
3
- * вписываются по четыре ложных числа, как это сделал Hermes, и прогоняется проверка с подменёнными
4
- * моделями, без сети и денег. Каждая ошибка должна быть исправлена на верное значение или отчёт удержан.
5
- * На отчётах без подсадки ни одна верная фраза не должна стать неверной.
6
- */
7
- import { readFileSync } from 'node:fs';
8
- import { replayer } from './etalon.mjs';
9
- import { collect } from './collect.mjs';
10
- import { secondLook } from './verify.mjs';
11
- import { draftNarrative } from './narrative.mjs';
12
- import { statementsOf, applyFix, reviewAndFix } from './review.mjs';
13
- import { numbersOf } from './facts.mjs';
14
-
15
- const dir = new URL('../test/etalon/', import.meta.url);
16
- // [id, число было, число стало]: те же места, что у Hermes.
17
- const PLANTS = {
18
- medeq: [['chk:api-catalog:comment', '9727', '29188'], ['sum:lead', '20', '67'], ['sum:verdict', null, '79'], ['sum:prio:0', '120', '367']],
19
- neronov: [['chk:api-catalog:comment', '9727', '29188'], ['sub:subject', '23 000', '76 000'], ['sum:lead', '20', '67'], ['sum:verdict', null, '28']],
20
- brainmd: [['chk:api-catalog:comment', '9727', '29188'], ['sum:lead', '20', '67'], ['sum:verdict', null, '49'], ['sum:prio:1', null, '148']],
21
- drivee: [['chk:api-catalog:comment', '9727', '29188'], ['sum:lead', '20', '67'], ['sum:verdict', null, '52'], ['sum:prio:0', null, '145']],
22
- babynanny: [['chk:api-catalog:comment', '9727', '29188'], ['sub:subject', '13 000', '46 000'], ['sum:lead', null, '61'], ['sum:verdict', null, '40']],
23
- };
24
-
25
- const json = (o) => ({ text: JSON.stringify(o), usage: { input: 1000, output: 100 } });
26
- /** Первый шаг: всё «ok», кроме заданных id (их помечает ложными). */
27
- const firstStep = (flag = new Set()) => async (messages) => {
28
- const st = JSON.parse(messages[2].content).statements;
29
- return json({ items: st.map((s) => (flag.has(s.id) ? { id: s.id, verdict: 'false', why: 'подсадка', fix: s.text } : { id: s.id, verdict: 'ok', premiseTrue: true, fitsNiche: true, passesNow: false, possible: true })) });
30
- };
31
- const silent = async () => json({ items: [] });
32
- /** Второй шаг, который «подтверждает» порчу верного числа: правка должна быть отклонена. */
33
- const spoiler = async (messages) => {
34
- const fl = JSON.parse(messages[2].content).flagged;
35
- return json({ items: fl.map((x) => ({ id: x.id, keep: true, evidence: 'на глаз', fix: String(x.text).replace(/\d+/, (n) => String(Number(n) + 1)) })) });
36
- };
37
-
38
- async function build(name) {
39
- const truth = JSON.parse(readFileSync(new URL(`${name}-truth.json`, dir), 'utf8'));
40
- const real = globalThis.fetch;
41
- globalThis.fetch = replayer(new URL(`${name}.json.gz`, dir));
42
- const raw = await collect(truth.site, { pages: 20, tier: '9', lang: truth.lang, rendered: false, log: () => {}, pageDelayMs: 0 });
43
- const second = await secondLook(raw, { maxPages: 120, probes: false, log: () => {} });
44
- globalThis.fetch = real;
45
- return { audit: draftNarrative(raw, { lang: truth.lang }), digest: second.digest || { robots: '', pages: [] }, lang: truth.lang };
46
- }
47
-
48
- const textMap = (audit, lang) => Object.fromEntries(statementsOf(audit, { lang }).map((s) => [s.id, s.text]));
49
- /** Подсадить: заменить число «было» (или первое число строки) на «стало». */
50
- function plant(audit, lang, [id, from, to]) {
51
- const t = textMap(audit, lang)[id];
52
- if (!t) throw new Error(`нет строки ${id}`);
53
- const target = from || numbersOf(t)[0];
54
- if (!target) throw new Error(`в строке ${id} нет числа: «${t}»`);
55
- const re = new RegExp(`(?<!\\d)(?<!\\d[\\u00A0\\u202F ])${target.replace(/ /g, '[\\u00A0\\u202F ]')}(?![\\d])`);
56
- if (!re.test(t)) throw new Error(`в строке ${id} нет числа ${target}: «${t}»`);
57
- applyFix(audit, id, t.replace(re, to));
58
- return { id, from: target, to, before: t };
59
- }
60
-
61
- let fail = 0; const table = [];
62
- for (const name of Object.keys(PLANTS)) {
63
- const row = { site: name, fixed: 0, held: 0, missed: 0, spoiled: 0 };
64
-
65
- // 1. Подсадка, первый шаг ничего не замечает, второй молчит: ловить обязан код.
66
- {
67
- const { audit, digest, lang } = await build(name);
68
- const planted = PLANTS[name].map((p) => plant(audit, lang, p));
69
- const rev = await reviewAndFix(audit, digest, firstStep(), { lang, askConfirm: silent });
70
- const after = textMap(audit, lang);
71
- for (const p of planted) {
72
- const now = after[p.id] || '';
73
- const fixed = !numbersOf(now).includes(p.to.replace(/ /g, '')) && now === p.before;
74
- if (fixed) row.fixed++; else if (!rev.ok) row.held++; else { row.missed++; fail++; console.log(`FAIL ${name} ${p.id}: «${p.to}» прошло молча: «${now}»`); }
75
- }
76
- }
77
- // 2. Подсадка, первый шаг помечает ещё и верную строку, второй молчит: отчёт удержан с причиной.
78
- {
79
- const { audit, digest, lang } = await build(name);
80
- PLANTS[name].forEach((p) => plant(audit, lang, p));
81
- const other = statementsOf(audit, { lang }).find((s) => s.kind === 'claim' && !PLANTS[name].some((p) => p[0] === s.id));
82
- const rev = await reviewAndFix(audit, digest, firstStep(new Set([other.id])), { lang, askConfirm: silent });
83
- if (rev.ok || !/не ответил по 1 строке/.test(rev.held || '')) { fail++; console.log(`FAIL ${name}: молчание второго шага не удержало отчёт (${rev.held || 'ok'}), строка ${other.id}, круги ${JSON.stringify(rev.rounds.map((r) => ({ f: r.flagged, c: r.byCode, m: r.missing })))}`); }
84
- }
85
- // 2б. Ответ в «грязной» форме (```json, keep строкой, id с пробелом) это ответ, а не молчание.
86
- {
87
- const { audit, digest, lang } = await build(name);
88
- const other = statementsOf(audit, { lang }).find((s) => s.kind === 'claim');
89
- const messy = async (messages) => { const fl = JSON.parse(messages[2].content).flagged; return { text: '```json\n' + JSON.stringify({ items: fl.map((x) => ({ id: ` ${x.id} `, keep: 'false' })) }) + '\n```', usage: { input: 1, output: 1 } }; };
90
- const rev = await reviewAndFix(audit, digest, firstStep(new Set([other.id])), { lang, askConfirm: messy });
91
- if (!rev.ok || rev.held) { fail++; console.log(`FAIL ${name}: «грязный» ответ второго шага принят за молчание (${rev.held})`); }
92
- }
93
- // 2в. Русская правка строки с латиницей («www») не затирается переводом: иначе круги не сходятся (cimes.ru, 14.10.2026).
94
- if (name === 'medeq') {
95
- const { audit, lang } = await build(name);
96
- applyFix(audit, 'chk:www:value', 'Склейка адреса с www: адрес с www перенаправляется на основной');
97
- const t = textMap(audit, lang)['chk:www:value'];
98
- if (t !== 'Склейка адреса с www: адрес с www перенаправляется на основной') { fail++; console.log(`FAIL правка www затёрта переводом: «${t}»`); }
99
- }
100
- // 3. Без подсадки: ни одна верная фраза не меняется, даже если второй шаг «подтверждает» порчу чисел.
101
- for (const [label, first, confirm] of [['всё верно', firstStep(), silent], ['порча чисел', firstStep(new Set(['sum:lead', 'sum:verdict', 'sum:prio:0'])), spoiler]]) {
102
- const { audit, digest, lang } = await build(name);
103
- const mine = textMap(audit, lang);
104
- await reviewAndFix(audit, digest, first, { lang, askConfirm: confirm });
105
- const after = textMap(audit, lang);
106
- for (const [id, t] of Object.entries(mine)) if (after[id] !== undefined && after[id] !== t) { row.spoiled++; fail++; console.log(`FAIL ${name} (${label}) ${id}: верное «${t}» стало «${after[id]}»`); }
107
- }
108
- table.push(row);
109
- }
110
- for (const r of table) console.log(`${r.site.padEnd(10)} исправлено ${r.fixed}, удержано ${r.held}, пропущено ${r.missed}, испорчено верных ${r.spoiled}`);
111
- const total = table.reduce((a, r) => ({ fixed: a.fixed + r.fixed, held: a.held + r.held, missed: a.missed + r.missed }), { fixed: 0, held: 0, missed: 0 });
112
- console.log(`подсадка: ${total.fixed + total.held} из 20 исправлены или удержаны, пропущено молча ${total.missed}`);
113
- if (fail) process.exit(1);
114
- console.log('PASS planted errors');
@@ -1,67 +0,0 @@
1
- /**
2
- * Проверка оформления (presentation.mjs): каждое из восьми правил отдельным тестом на настоящем отчёте
3
- * эталона, куда вписан один огрех. Плюс все эталоны без огрехов должны проходить чисто.
4
- */
5
- import { readdirSync, readFileSync } from 'node:fs';
6
- import { replayer } from './etalon.mjs';
7
- import { collect } from './collect.mjs';
8
- import { secondLook } from './verify.mjs';
9
- import { draftNarrative } from './narrative.mjs';
10
- import { presentationCheck } from './presentation.mjs';
11
-
12
- const dir = new URL('../test/etalon/', import.meta.url);
13
- let bad = 0;
14
- const is = (n, c) => { if (!c) { bad++; console.error(`FAIL ${n}`); } else console.log(`ok ${n}`); };
15
-
16
- async function build(name) {
17
- const truth = JSON.parse(readFileSync(new URL(`${name}-truth.json`, dir), 'utf8'));
18
- const real = globalThis.fetch;
19
- globalThis.fetch = replayer(new URL(`${name}.json.gz`, dir));
20
- const raw = await collect(truth.site, { pages: 20, tier: '9', lang: truth.lang, rendered: false, log: () => {}, pageDelayMs: 0 });
21
- await secondLook(raw, { maxPages: 120, probes: false, log: () => {} });
22
- globalThis.fetch = real;
23
- return { a: draftNarrative(raw, { lang: truth.lang }), lang: truth.lang };
24
- }
25
-
26
- // Все эталоны чистые.
27
- const names = readdirSync(dir).filter((x) => x.endsWith('-truth.json')).map((x) => x.replace('-truth.json', '')).sort();
28
- for (const name of names) {
29
- const { a, lang } = await build(name);
30
- const r = presentationCheck(a, { lang });
31
- is(`${name}: оформление чистое (${r.problems.map((p) => `[${p.rule}] ${p.id} ${p.why}`).join('; ') || 'ни одной находки'})`, r.ok);
32
- }
33
-
34
- // По одному огреху на правило, на отчёте cimes.ru.
35
- const fires = async (rule, label, spoil) => {
36
- const { a, lang } = await build('cimes');
37
- spoil(a);
38
- const r = presentationCheck(a, { lang });
39
- is(`правило ${rule}: ${label}`, r.problems.some((p) => p.rule === rule));
40
- };
41
- await fires(1, 'пустой раздел «Неделя 1»', (a) => { a.roadmap[0].items = []; });
42
- await fires(2, '«Сейчас: 1: t.me»', (a) => { a.summary.priorities[0] = 'Заявить профили. Сейчас: 1: t.me'; });
43
- await fires(2, 'undefined в тексте', (a) => { a.summary.lead += ' балл undefined'; });
44
- await fires(3, 'фраза оборвана на «на»', (a) => { a.summary.verdict = 'Та же дата видна на самой странице на'; });
45
- await fires(3, 'фраза оборвана на «на 1»', (a) => { const c = a.checks.find((x) => x.id === 'dates'); c.value = '1 из 20 страниц выборки указывают дату в разметке; та же дата видна на самой странице на 1'; });
46
- await fires(4, '«Норма» при 1 из 20', (a) => { const c = a.checks.find((x) => x.id === 'dates'); c.status = 'ok'; c.value = '1 из 20 страниц выборки указывают дату в разметке'; });
47
- await fires(5, '«Тонкие страницы» в сильных сторонах', (a) => { a.content.strengths = ['Тонкие страницы: медиана 1618 слов на страницу', ...(a.content.strengths || [])]; });
48
- await fires(6, '«не измеряли» при найденном движке', (a) => { a.checks.push({ id: 'cms', group: 'overview', label: 'Движок сайта', status: 'na', value: '1C-Bitrix (определён по коду страницы)' }); a.overview.rows[0][1] = 'не измеряли'; });
49
- await fires(7, 'в отчёте 5 страниц, в задаче 2', (a) => { const c = a.checks.find((x) => x.id === 'sections'); c.status = 'warn'; c.value = 'меньше трёх подзаголовков H2 на 5 из 20 страниц выборки'; a.taskOverrides = { sections: { task: 'Разбей на разделы страницы /a/, /b/.' } }; });
50
- await fires(8, 'длинное тире в нашей фразе', (a) => { a.summary.verdict = 'Сайт в порядке — почти'; });
51
-
52
- // Команда для разработчика с /dev/null не огрех.
53
- {
54
- const { a, lang } = await build('cimes');
55
- a.taskOverrides = { ...(a.taskOverrides || {}), sections: { verify: 'Три замера (curl -o /dev/null -w "%{time_starttransfer}" адрес главной) показывают не больше 0,8 секунды.' } };
56
- is('команда curl с /dev/null не огрех', !presentationCheck(a, { lang }).problems.some((p) => p.rule === 2));
57
- }
58
-
59
- // Тире в цитате с сайта клиента не огрех.
60
- {
61
- const { a, lang } = await build('cimes');
62
- a.client.subject = 'По описанию на главной: Студия — сайты под ключ.';
63
- is('тире в цитате с сайта клиента не огрех', !presentationCheck(a, { lang }).problems.some((p) => p.rule === 8));
64
- }
65
-
66
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
67
- console.log('\nпроверка оформления: все правила работают, эталоны чистые');