@operstack/audit 0.28.0 → 0.30.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "scripts": {
32
- "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs",
32
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs",
33
33
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
34
34
  "verify:live": "node src/verify-live.mjs"
35
35
  },
package/src/collect.mjs CHANGED
@@ -641,8 +641,13 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
641
641
  const basis = visibility && visibility.ok
642
642
  ? { pages: (visibility.sample || []).length, sitemapRead: Boolean(visibility.sitemap && visibility.sitemap.found), sitemapUnchecked: Boolean(visibility.sitemap && visibility.sitemap.unchecked) }
643
643
  : null;
644
+ /*
645
+ * Готовность по движкам и основы SEO переезжают в платный отчёт из той же проверки, что уже
646
+ * посчитана выше. Ни одного лишнего запроса: до 17.09.2026 бесплатная страница показывала то,
647
+ * чего в платном отчёте не было вовсе, и покупатель за это справедливо спросил бы.
648
+ */
644
649
  const overall = visibility && visibility.ok
645
- ? { score: visibility.score, grade: visibility.grade, areas: visibility.areas, basis, source: givenVisibility ? 'visibility:reused' : 'visibility', measuredAt, ms: visibility.ms }
650
+ ? { score: visibility.score, grade: visibility.grade, areas: visibility.areas, basis, source: givenVisibility ? 'visibility:reused' : 'visibility', measuredAt, ms: visibility.ms, engines: visibility.engines || [], seo: visibility.seo || null }
646
651
  : { score: null, grade: 'not measured', areas: [], basis: null, source: givenVisibility ? 'visibility:reused' : 'visibility', error: (visibility && visibility.error) || 'no visibility result' };
647
652
  // Шесть областей отчёта остаются, но это другое измерение, а не разбивка общего балла.
648
653
  const reportScore = computeOverall(checks);
@@ -679,7 +684,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
679
684
  closing: '{{Key message for the client in three sentences.}}',
680
685
  // В выборке остаётся и то, из чего считается работа с текстами: без этих полей пакет
681
686
  // Foundation пришлось бы оценивать на глаз, как раньше оценивался Fix.
682
- sample: sample.map((p) => p.title !== undefined ? { url: p.url, title: p.title, words: p.words, h1Count: p.h1Count, images: p.images, imagesNoAlt: p.imagesNoAlt, schemaTypes: p.schemaTypes, answerFirst: p.answerFirst, sourcePhrases: p.sourcePhrases, citedParagraphs: p.citedParagraphs, figureParagraphs: p.figureParagraphs, h2Count: p.h2Count, tables: p.tables, firstParaWords: p.firstParaWords, dates: p.dates } : p),
687
+ sample: sample.map((p) => p.title !== undefined ? { url: p.url, title: p.title, words: p.words, h1Count: p.h1Count, linkCount: Array.isArray(p.links) ? p.links.length : null, images: p.images, imagesNoAlt: p.imagesNoAlt, schemaTypes: p.schemaTypes, answerFirst: p.answerFirst, sourcePhrases: p.sourcePhrases, citedParagraphs: p.citedParagraphs, figureParagraphs: p.figureParagraphs, h2Count: p.h2Count, tables: p.tables, firstParaWords: p.firstParaWords, dates: p.dates } : p),
683
688
  notes,
684
689
  };
685
690
  }
package/src/narrative.mjs CHANGED
@@ -19,10 +19,17 @@
19
19
 
20
20
  const T = {
21
21
  ru: {
22
- subjectFrom: (d) => `По описанию на главной: ${d}. Подтвердите, что это верно.`,
22
+ subjectFrom: (d) => `По описанию на главной: ${d}. Это то, что о вас прочитает и человек, и поисковик, и ИИ.`,
23
23
  subjectNone: 'На главной не сказано, чем занимается компания. Это и есть первая проблема: если это непонятно человеку, это непонятно и поисковику, и ИИ.',
24
24
  unknownLang: 'не указан в коде страниц',
25
- sells: (d) => d ? `${d} (взято из описания главной, подтвердите)` : 'из описания на главной не понять, подтвердите',
25
+ /*
26
+ * Обращение к аналитику из клиентского текста убрано 17.09.2026. «Подтвердите» писалось для
27
+ * человека, который правит черновик перед отправкой, а в автоматическом товаре за 800 ₽ оно
28
+ * уезжало покупателю как есть. Теперь строка говорит, что именно прочитано и откуда, и это
29
+ * само по себе находка: если из описания главной не понять, чем занимается компания, то это
30
+ * не понять ни человеку, ни поисковику, ни ИИ.
31
+ */
32
+ sells: (d) => d ? `${d} (прочитано из описания главной страницы)` : 'на главной не сказано, чем занимается компания: ни человеку, ни поисковику, ни ИИ это отсюда не понять',
26
33
  leadWhat: (h, n) => `${h} это сайт из ${n} ${plural(n, 'страницы', 'страниц', 'страниц')}, и он читается машинами: мы открыли его так же, как их открывают поисковик и ИИ.`,
27
34
  leadGood: (areas) => areas.length ? `Хорошо сделано вот что: ${listRu(areas)}.` : 'Ни одна из шести областей пока не выглядит сильной стороной.',
28
35
  leadBad: (area, n) => n ? `Держит сайт назад другое: ${n} ${plural(n, 'проверка провалена', 'проверки провалены', 'проверок провалено')}, тяжелее всего дела в области «${area}».` : `Проваленных проверок нет, но до потолка не хватает мелочей, тяжелее всего в области «${area}».`,
@@ -61,10 +68,10 @@ const T = {
61
68
  quarterFill: 'Повторная проверка и сравнение с этим отчётом',
62
69
  },
63
70
  en: {
64
- subjectFrom: (d) => `From the homepage description: ${d}. Please confirm this is right.`,
71
+ subjectFrom: (d) => `From the homepage description: ${d}. This is what a person, a search engine and an AI all read about you.`,
65
72
  subjectNone: 'The homepage does not say what the business does. That is the first problem: if a person cannot tell, neither can a search engine or an AI.',
66
73
  unknownLang: 'not declared in the page code',
67
- sells: (d) => d ? `${d} (taken from the homepage description, please confirm)` : 'the homepage description does not say, please confirm',
74
+ sells: (d) => d ? `${d} (read from the homepage description)` : 'the homepage does not say what the company does: neither a person, nor a search engine, nor an AI can tell from it',
68
75
  leadWhat: (h, n) => `${h} is a site of ${n} page${n === 1 ? '' : 's'}, and it was read the way machines read it: the way a search engine and an AI open it.`,
69
76
  leadGood: (areas) => areas.length ? `What is done well: ${listEn(areas)}.` : 'None of the six areas reads as a strength yet.',
70
77
  leadBad: (area, n) => n ? `What holds it back: ${n} check${n === 1 ? '' : 's'} failed, and the hardest area is "${area}".` : `No check is failing, but the last points are missing, mostly in "${area}".`,
@@ -134,7 +141,16 @@ export function draftNarrative(audit, opts = {}) {
134
141
  // иначе владелец читает русский текст с английскими вставками и перестаёт нам верить.
135
142
  const needsRu = lang === 'ru' && a.meta?.lang !== 'ru';
136
143
  const checks = needsRu ? localiseChecks(a.checks || [], 'ru') : (a.checks || []);
137
- const areaName = (k) => (needsRu ? (AREAS_RU[k] || k) : k);
144
+ /*
145
+ * Названия областей переводим по языку ОТЧЁТА, а не по языку исходного аудита.
146
+ *
147
+ * Было завязано на needsRu, то есть переводилось только тогда, когда аудит собран
148
+ * по-английски. У русского аудита ключи областей всё равно английские (они приходят из
149
+ * collect), и в русский текст уезжало «Хорошо сделано вот что: SEO, technical, SEO, content
150
+ * and structure...», а в карточках ниже те же области стояли по-русски. Два языка на одной
151
+ * странице в отчёте за деньги.
152
+ */
153
+ const areaName = (k) => (lang === 'ru' ? (AREAS_RU[k] || k) : k);
138
154
  const host = a.meta?.host || '';
139
155
  const sample = (a.sample || []).filter((p) => p.title !== undefined);
140
156
  const home = sample[0] || {};
package/src/render.mjs CHANGED
@@ -46,6 +46,8 @@ const LABELS = {
46
46
  return `${where}${read}${sumUp}Running the check again can move it by a point or two: a check that answers in time on one run may not on the next, and this score never counts against a site what it could not read.`;
47
47
  },
48
48
  secondMeasure: 'A second, separate measurement: how this report scores its own checks',
49
+ ofHundred: 'of 100', ofHundredShort: 'OF 100', aiVisibility: 'AI visibility', seoBasics: 'SEO basics',
50
+ enginesHead: 'Readiness by engine', madeOf: 'What the score is made of', worth: 'worth', blockedIn: 'Blocked', colTables: 'Tables', colLinks: 'Links',
49
51
  secOverview: 'Site overview', overviewEyebrow: '02 · Overview', whatSiteIs: 'What the site is', parameter: 'Parameter', value: 'Value', pagesSampled: 'Pages sampled', colUrl: 'URL', colTitle: 'Title', colWords: 'Words',
50
52
  secCritical: 'Critical issues', criticalEyebrow: '03 · P0', criticalTitle: 'Critical issues, fix first', whatItCosts: 'What it costs', theFix: 'Fix', noBody: 'This issue has no description in the audit file.', noCritical: 'No critical defects were found in the public signals.',
51
53
  secTechnical: 'Technical SEO', technicalEyebrow: '04 · Technical', technicalTitle: 'Technical and on-page checklist', colCheck: 'Check', colStatus: 'Status', colFinding: 'Finding',
@@ -86,6 +88,8 @@ const LABELS = {
86
88
  return `${where}${read}${sumUp}Повторная проверка может дать на пункт-другой иначе: проверка, которая успела ответить в один прогон, может не успеть в следующий, а балл никогда не засчитывает сайту в минус то, что не удалось прочитать.`;
87
89
  },
88
90
  secondMeasure: 'Второе, отдельное измерение: как этот отчёт оценивает собственные проверки',
91
+ ofHundred: 'из 100', ofHundredShort: 'ИЗ 100', aiVisibility: 'ИИ-видимость', seoBasics: 'Основы SEO',
92
+ enginesHead: 'Готовность по движкам', madeOf: 'Из чего сложился балл', worth: 'вес', blockedIn: 'Закрыты', colTables: 'Таблиц', colLinks: 'Ссылок',
89
93
  secOverview: 'О сайте', overviewEyebrow: '02 · Обзор', whatSiteIs: 'Что это за сайт', parameter: 'Параметр', value: 'Значение', pagesSampled: 'Проверенные страницы', colUrl: 'Адрес', colTitle: 'Заголовок', colWords: 'Слов',
90
94
  secCritical: 'Критичное', criticalEyebrow: '03 · Срочно', criticalTitle: 'Что чинить первым', whatItCosts: 'Чем это грозит', theFix: 'Как чинится', noBody: 'У этой проблемы нет описания в файле аудита.', noCritical: 'Критичных дефектов в публичных сигналах не найдено.',
91
95
  secTechnical: 'Техническое SEO', technicalEyebrow: '04 · Техника', technicalTitle: 'Техническая проверка и страницы', colCheck: 'Проверка', colStatus: 'Статус', colFinding: 'Что нашли',
@@ -194,13 +198,52 @@ function css() {
194
198
  table.areas td { text-align: center; padding: 2.5mm; border: 1px solid var(--rule); font-family: "Fraunces", Georgia, serif; font-size: 13pt; font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; }
195
199
  table.areas td span { font-family: inherit; font-size: 9pt; color: var(--muted); font-weight: 400; }
196
200
  h3.second-measure { font-size: 11pt; margin: 7mm 0 3mm; }
197
- .overall { display: flex; align-items: center; gap: 6mm; border: 1px solid var(--rule); border-radius: 5px; padding: 5mm 6mm; background: #f4f8f8; margin: 5mm 0 4mm; }
198
- .overall-num { font-family: "Fraunces", Georgia, serif; font-size: 44pt; font-weight: 600; line-height: 1; color: var(--accent); font-variant-numeric: tabular-nums; }
199
- .overall-num span { font-size: 18pt; color: var(--muted); }
200
- .overall-num.low { color: var(--danger); } .overall-num.mid { color: var(--warn); } .overall-num.ok { color: var(--ok); }
201
- .overall-label { font-size: 8pt; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
202
- .overall-grade { font-family: "Fraunces", Georgia, serif; font-size: 15pt; font-weight: 600; margin: 1mm 0 1.5mm; }
203
- .overall-note { font-size: 8.5pt; color: var(--muted); line-height: 1.45; }
201
+ /*
202
+ * Сводка: кольцо, пилюли, полосы. Те же пороги цвета, что на странице результата: 80 и 45.
203
+ * Печать на бумагу, поэтому все размеры в миллиметрах и пунктах, а не в пикселях.
204
+ */
205
+ .overall { display: flex; align-items: center; gap: 7mm; border: 1px solid var(--rule); border-radius: 5px; padding: 4mm 6mm; background: #fafbfb; margin: 5mm 0 4mm; }
206
+ .ring { flex: none; color: var(--ok); }
207
+ .ring.ring-fair { color: #d9a030; } .ring.ring-poor { color: #d2694f; } .ring.ring-na { color: var(--muted); }
208
+ .ring-num { font-family: "Fraunces", Georgia, serif; font-size: 42px; font-weight: 600; fill: var(--ink); }
209
+ .ring-of { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 11px; letter-spacing: 1px; fill: var(--muted); }
210
+ .overall-side { min-width: 0; }
211
+ .pills { display: flex; flex-wrap: wrap; gap: 2mm; margin: 0 0 2.5mm; }
212
+ .pill { display: inline-flex; align-items: baseline; gap: 1.6mm; padding: 1.4mm 3mm; border-radius: 99mm; background: var(--accent-soft); font-size: 7.5pt; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
213
+ .pill-fair { background: #fbf0d5; } .pill-poor { background: #f9e1db; } .pill-na { background: #eceef0; }
214
+ .pill b { font-family: "Fraunces", Georgia, serif; font-size: 12pt; letter-spacing: 0; color: var(--ok); }
215
+ .pill-fair b { color: #9a6700; } .pill-poor b { color: #b3261e; } .pill-na b { color: var(--muted); }
216
+ .pill-n { color: var(--ink-2); }
217
+ .overall-grade { font-family: "Fraunces", Georgia, serif; font-size: 14pt; font-weight: 600; margin: 0 0 1.5mm; }
218
+ .overall-note { font-size: 8.5pt; color: var(--muted); line-height: 1.45; margin: 0; }
219
+
220
+ h3.engines-head, h3.areas-head { font-size: 11pt; margin: 6mm 0 2.5mm; }
221
+ .engines { display: grid; grid-template-columns: repeat(5, 1fr); gap: 2.2mm; }
222
+ .eng { border: 1px solid var(--rule); border-radius: 4px; padding: 2.4mm 2.6mm; background: #fafbfb; }
223
+ .eng p { margin: 0; }
224
+ .eng-name { font-size: 8pt; font-weight: 600; }
225
+ .eng-num { font-family: "Fraunces", Georgia, serif; font-size: 16pt; line-height: 1; color: var(--ok); margin: 0.6mm 0 1.4mm; font-variant-numeric: tabular-nums; }
226
+ .eng-fair .eng-num { color: #9a6700; } .eng-poor .eng-num { color: #b3261e; } .eng-na .eng-num { color: var(--muted); }
227
+ .eng-how { font-size: 6.2pt; color: var(--muted); line-height: 1.3; margin-top: 1.4mm; }
228
+ .eng-poor .eng-how { color: #b3261e; }
229
+
230
+ .pbar { height: 1.3mm; border-radius: 1mm; background: #ecefee; overflow: hidden; }
231
+ .pbar i { display: block; height: 100%; background: var(--ok); }
232
+ .eng-fair .pbar i, .ar-fair .pbar i { background: #e8b84a; }
233
+ .eng-poor .pbar i, .ar-poor .pbar i { background: #d2694f; }
234
+ .eng-na .pbar i, .ar-na .pbar i { background: var(--muted); }
235
+
236
+ .areas-bars { margin-bottom: 2mm; }
237
+ .ar { padding: 2mm 0; border-top: 1px solid var(--rule); }
238
+ .ar:first-child { border-top: 0; padding-top: 0.5mm; }
239
+ .ar-h { display: grid; grid-template-columns: 1fr auto auto; gap: 3mm; align-items: baseline; margin-bottom: 1.4mm; }
240
+ .ar-n { font-size: 9pt; font-weight: 600; }
241
+ .ar-w { font-size: 7pt; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); }
242
+ .ar-v { font-family: "Fraunces", Georgia, serif; font-size: 13pt; color: var(--ok); font-variant-numeric: tabular-nums; min-width: 14mm; text-align: right; }
243
+ .ar-fair .ar-v { color: #9a6700; } .ar-poor .ar-v { color: #b3261e; }
244
+ .ar-v span { font-size: 8pt; color: var(--muted); }
245
+ .ar-v .na { font-family: inherit; font-size: 8pt; color: var(--muted); }
246
+
204
247
  .scorecard { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin: 5mm 0 6mm; }
205
248
  .score { border: 1px solid var(--rule); border-radius: 4px; padding: 3.5mm 4mm; background: #fafbfb; }
206
249
  .score .label { font-size: 8pt; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 1.5mm; }
@@ -264,12 +307,44 @@ export function toHtml(audit, brand = null) {
264
307
  * заголовком не нужно и не получится, и об этом сказано словами.
265
308
  */
266
309
  const head = overallSummary(a.overall, { lang: a.meta?.lang === 'ru' ? 'ru' : 'en' });
310
+ /*
311
+ * Сводка на языке, которым с 17.09.2026 говорит страница результата: кольцо со шкалой, две
312
+ * пилюли с числами, полосы по областям, цвет по значению. До этого здесь были плоские числа в
313
+ * таблице, и владелец сказал прямо, что бесплатная страница выглядит лучше платного отчёта.
314
+ *
315
+ * Цвет считается одним правилом на весь отчёт, теми же порогами, что у буквы: 80 и 45.
316
+ */
317
+ const tone = (pct) => (pct === null ? 'na' : pct >= 80 ? 'good' : pct >= 45 ? 'fair' : 'poor');
318
+ const RING_R = 58;
319
+ const RING_C = 2 * Math.PI * RING_R;
320
+ const ring = (score) => `<svg class="ring ring-${tone(score)}" viewBox="0 0 148 148" width="118" height="118" role="img" aria-label="${score} ${esc(L.ofHundred || 'of 100')}">
321
+ <circle cx="74" cy="74" r="${RING_R}" fill="none" stroke="#ECE9E2" stroke-width="12"/>
322
+ <circle cx="74" cy="74" r="${RING_R}" fill="none" stroke="currentColor" stroke-width="12" stroke-linecap="round"
323
+ stroke-dasharray="${RING_C.toFixed(1)}" stroke-dashoffset="${(RING_C * (1 - score / 100)).toFixed(1)}" transform="rotate(-90 74 74)"/>
324
+ <text class="ring-num" x="74" y="80" text-anchor="middle">${score}</text>
325
+ <text class="ring-of" x="74" y="101" text-anchor="middle">${esc(L.ofHundredShort || 'OF 100')}</text>
326
+ </svg>`;
327
+ const grade100 = (n) => (n >= 80 ? 'A' : n >= 65 ? 'B' : n >= 45 ? 'C' : n >= 25 ? 'D' : 'E');
328
+ const pill = (label, score) => `<span class="pill pill-${tone(score)}"><span class="pill-k">${esc(label)}</span> <b>${grade100(score)}</b> <span class="pill-n">${score}/100</span></span>`;
329
+ const seo = a.overall && a.overall.seo && typeof a.overall.seo.score === 'number' ? a.overall.seo : null;
330
+ const engines = Array.isArray(a.overall && a.overall.engines) ? a.overall.engines : [];
331
+
267
332
  const overallBlock = !head ? '' : `<div class="overall">
268
- <div class="overall-num ${scoreClass(Math.round(head.score / 10))}">${head.score}<span>/100</span></div>
269
- <div class="overall-side"><div class="overall-label">${esc(head.label)}</div>
270
- <div class="overall-grade">${esc(head.grade)}</div>
271
- <div class="overall-note">${esc(head.note)}</div></div></div>`
272
- + (head.areas.length ? `<table class="areas"><tr>${head.areas.map((x) => `<th>${esc(x.label)}</th>`).join('')}</tr><tr>${head.areas.map((x) => `<td>${x.score === null ? `<span>${esc(head.notMeasured)}</span>` : `${x.score}<span>/${x.max}</span>`}</td>`).join('')}</tr></table>` : '');
333
+ ${ring(head.score)}
334
+ <div class="overall-side">
335
+ <p class="pills">${pill(L.aiVisibility || 'AI visibility', head.score)}${seo ? pill(L.seoBasics || 'SEO basics', seo.score) : ''}</p>
336
+ <div class="overall-grade">${esc(head.grade)}</div>
337
+ <p class="overall-note">${esc(head.note)}</p>
338
+ </div>
339
+ </div>`
340
+ + (engines.length ? `<h3 class="engines-head">${esc(L.enginesHead || 'Readiness by engine')}</h3><div class="engines">${engines.map((e) => {
341
+ const v = e.score === null ? 0 : e.score;
342
+ return `<div class="eng eng-${tone(e.score)}"><p class="eng-name">${esc(e.label)}</p><p class="eng-num">${e.score === null ? '&middot;' : e.score}</p><div class="pbar"><i style="width:${v}%"></i></div><p class="eng-how">${esc(e.blocked && e.blocked.length ? `${L.blockedIn || 'Blocked'}: ${e.blocked.join(', ')}` : e.how)}</p></div>`;
343
+ }).join('')}</div>` : '')
344
+ + (head.areas.length ? `<h3 class="areas-head">${esc(L.madeOf || 'What the score is made of')}</h3><div class="areas-bars">${head.areas.map((x) => {
345
+ const pct = x.score === null || !x.max ? null : Math.round((x.score / x.max) * 100);
346
+ return `<div class="ar ar-${tone(pct)}"><div class="ar-h"><span class="ar-n">${esc(x.label)}</span><span class="ar-w">${esc((L.worth || 'worth') + ' ' + x.max)}</span><span class="ar-v">${x.score === null ? `<span class="na">${esc(head.notMeasured)}</span>` : `${x.score}<span>/${x.max}</span>`}</span></div><div class="pbar"><i style="width:${pct === null ? 0 : pct}%"></i></div></div>`;
347
+ }).join('')}</div>` : '');
273
348
  const scoreCards = Object.entries(measured).map(([label, n]) => {
274
349
  const basis = scoreBasis[label] || {};
275
350
  const shown = (L.areas && L.areas[label]) || label;
@@ -290,17 +365,56 @@ export function toHtml(audit, brand = null) {
290
365
  <dl class="cover-meta"><div><dt>${L.auditSubject}</dt><dd>${t(a.client.name)}</dd></div><div><dt>${L.reportDate}</dt><dd>${esc(a.client.reportDate)}</dd></div><div><dt>${L.auditType}</dt><dd>${esc(a.meta.auditType)}</dd></div><div><dt>${L.preparedBy}</dt><dd>${esc(preparedBy)}</dd></div></dl></div>
291
366
  ${toolLine}</div></div>`);
292
367
  n++;
293
- pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p>${overallBlock}<h3 class="second-measure">${L.secondMeasure}</h3><div class="scorecard">${scoreCards}</div><p class="scorecard-foot">${L.scorecardFoot}</p><div class="verdict"><p><strong>${L.keyTakeaway}</strong> ${t(a.summary.verdict)}</p></div><h3>${L.threePriorities}</h3>${olist(a.summary.priorities)}${footer(a, n++, preparedBy)}</div>`);
294
- pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${L.overviewEyebrow}</div><h2>${L.whatSiteIs}</h2><table><tr><th>${L.parameter}</th><th>${L.value}</th></tr>${(a.overview.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><p>${t(a.overview.note)}</p><h3>${L.pagesSampled}</h3><table><tr><th>${L.colUrl}</th><th>${L.colTitle}</th><th>${L.colWords}</th><th>H1</th><th>Alt</th></tr>${(a.sample || []).filter((p) => p.title !== undefined).slice(0, 14).map((p) => `<tr><td><code>${esc(new URL(p.url).pathname)}</code></td><td>${esc(p.title)}</td><td>${p.words}</td><td>${p.h1Count}</td><td>${p.images ? `${p.images - p.imagesNoAlt}/${p.images}` : '·'}</td></tr>`).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
368
+ /*
369
+ * Сводка занимает ДВА листа намеренно, а не один с переливом.
370
+ *
371
+ * Раньше всё лежало на одной странице, не влезало, и хвост уезжал на свой лист: три строчки
372
+ * приоритетов и восемьдесят процентов белого. Таких листов в отчёте было три из двенадцати, и
373
+ * выглядело это как недоделанная работа. Теперь первый лист про балл и из чего он сложился,
374
+ * второй про проверки самого отчёта и что делать. Оба полные.
375
+ */
376
+ pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p>${overallBlock}${footer(a, n++, preparedBy)}</div>`);
377
+ pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secondMeasure}</h2><div class="scorecard">${scoreCards}</div><p class="scorecard-foot">${L.scorecardFoot}</p><div class="verdict"><p><strong>${L.keyTakeaway}</strong> ${t(a.summary.verdict)}</p></div><h3>${L.threePriorities}</h3>${olist(a.summary.priorities)}${footer(a, n++, preparedBy)}</div>`);
378
+ pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${L.overviewEyebrow}</div><h2>${L.whatSiteIs}</h2><table><tr><th>${L.parameter}</th><th>${L.value}</th></tr>${(a.overview.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><p>${t(a.overview.note)}</p><h3>${L.pagesSampled}</h3><table class="pages"><tr><th>${L.colUrl}</th><th>${L.colTitle}</th><th>${L.colWords}</th><th>H2</th><th>${L.colTables || 'Tables'}</th><th>${L.colLinks || 'Links'}</th><th>Alt</th></tr>${(a.sample || []).filter((p) => p.title !== undefined).slice(0, 14).map((p) => `<tr><td><code>${esc(new URL(p.url).pathname)}</code></td><td>${esc(p.title)}</td><td>${p.words}</td><td>${p.h2Count ?? '·'}</td><td>${p.tables ?? '·'}</td><td>${p.linkCount ?? '·'}</td><td>${p.images ? `${p.images - p.imagesNoAlt}/${p.images}` : '·'}</td></tr>`).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
295
379
  const cardBody = (c) => [
296
380
  c.text ? `<p>${t(c.text)}</p>` : '',
297
381
  c.cost ? `<p><span class="card-tag">${L.whatItCosts}</span>${t(c.cost)}</p>` : '',
298
382
  c.fix ? `<p><span class="card-tag">${L.theFix}</span>${t(c.fix)}</p>` : '',
299
383
  ].join('') || '<p class="card-empty">${L.noBody}</p>';
300
384
  const cards = (a.critical || []).map((c, i) => `<div class="card ${c.level === 'warn' ? 'warn' : ''}"><h4>${i + 1}. ${t(c.title)}</h4>${cardBody(c)}</div>`).join('') || `<p>${L.noCritical}</p>`;
301
- pages.push(`<div class="page">${header(a, L.secCritical, L)}<div class="eyebrow">${L.criticalEyebrow}</div><h2>${L.criticalTitle}</h2><div class="cards">${cards}</div>${footer(a, n++, preparedBy)}</div>`);
302
- const techRows = [...checksBy('technical'), ...checksBy('onpage')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`).join('');
303
- pages.push(`<div class="page">${header(a, L.secTechnical, L)}<div class="eyebrow">${L.technicalEyebrow}</div><h2>${L.technicalTitle}</h2><table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${techRows}</table>${footer(a, n++, preparedBy)}</div>`);
385
+ /*
386
+ * Лист «что чинить первым» печатается, только когда есть что чинить. Раньше на чистом сайте
387
+ * он занимал целую страницу одной строкой «критичных дефектов не найдено», а это в отчёте за
388
+ * деньги выглядит как пустой лист. Сам факт не пропадает: он сказан в сводке.
389
+ */
390
+ if ((a.critical || []).length) {
391
+ pages.push(`<div class="page">${header(a, L.secCritical, L)}<div class="eyebrow">${L.criticalEyebrow}</div><h2>${L.criticalTitle}</h2><div class="cards">${cards}</div>${footer(a, n++, preparedBy)}</div>`);
392
+ }
393
+ /*
394
+ * Длинная таблица режется на листы по счёту строк, а не переливается сама.
395
+ *
396
+ * Перелив оставлял лист с четырьмя строками и восемьюдесятью процентами белого: в отчёте за
397
+ * деньги это читается как недоделанная работа. Порог взят по факту: строка занимает примерно
398
+ * 11 мм, на лист влезает около восемнадцати вместе с заголовком. Строка с пояснением выше,
399
+ * поэтому считаем её за полторы.
400
+ */
401
+ const techChecks = [...checksBy('technical'), ...checksBy('onpage')];
402
+ const techHead = `<tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>`;
403
+ const rowHtml = (c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`;
404
+ const TECH_UNITS_PER_PAGE = 17;
405
+ const techPages = [];
406
+ let bucket = [];
407
+ let units = 0;
408
+ for (const c of techChecks) {
409
+ const cost = c.comment ? 1.5 : 1;
410
+ if (units + cost > TECH_UNITS_PER_PAGE && bucket.length) { techPages.push(bucket); bucket = []; units = 0; }
411
+ bucket.push(c); units += cost;
412
+ }
413
+ if (bucket.length) techPages.push(bucket);
414
+ techPages.forEach((chunk, i) => {
415
+ const title = techPages.length > 1 ? `${L.technicalTitle} (${i + 1}/${techPages.length})` : L.technicalTitle;
416
+ pages.push(`<div class="page">${header(a, L.secTechnical, L)}<div class="eyebrow">${L.technicalEyebrow}</div><h2>${esc(title)}</h2><table>${techHead}${chunk.map(rowHtml).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
417
+ });
304
418
  pages.push(`<div class="page">${header(a, L.secContent, L)}<div class="eyebrow">${L.contentEyebrow}</div><h2>${L.contentTitle}</h2><div class="two-col"><div><h3>${L.strengths}</h3>${list(a.content.strengths)}</div><div><h3>${L.weaknesses}</h3>${list(a.content.weaknesses)}</div></div><h3>${L.gaps}</h3>${list(a.content.gaps)}${checksBy('content').length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy('content').map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}</td></tr>`).join('')}</table>` : ''}${footer(a, n++, preparedBy)}</div>`);
305
419
  const geoRows = [...checksBy('aeo'), ...checksBy('geo')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `. ${esc(c.comment.charAt(0).toUpperCase() + c.comment.slice(1))}` : ''}</td></tr>`).join('') + (a.geo.rows || []).map(([k, s, d]) => `<tr><td>${t(k)}</td><td>${t(s)}</td><td>${t(d)}</td></tr>`).join('');
306
420
  pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${L.aeoTitle}</h2><p class="lead">Whether the site's answers can be lifted into "People also ask", AI overviews, ChatGPT and Perplexity.</p><div class="two-col"><div><h3>${L.aeoWorks}</h3>${list(a.aeo.works)}</div><div><h3>${L.aeoBlocks}</h3>${list(a.aeo.blocks)}</div></div><div class="callout"><p><strong>${L.recommendation}</strong> ${t(a.aeo.recommendation)}</p></div><h2>${L.geoTitle}</h2><table><tr><th>${L.colSignal}</th><th>${L.colStatus}</th><th>${L.colDetail}</th></tr>${geoRows}</table><div class="callout"><p>${t(a.geo.callout)}</p></div>${footer(a, n++, preparedBy)}</div>`);
@@ -41,7 +41,19 @@ const audit = (over = {}) => ({
41
41
  const d = draftNarrative(audit(), { lang: 'ru' });
42
42
  is('не осталось ни одной дыры', stillEmpty(d), []);
43
43
  ok('имя клиента это адрес сайта, а не выдумка', d.client.name === 'x.ru');
44
- ok('предмет взят с главной и помечен как прочитанный', d.client.subject.includes('Мы продаём станки') && /подтвердите/i.test(d.client.subject));
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
+ }
45
57
 
46
58
  // Ничего не выдумываем: там, где не мерили, так и написано
47
59
  ok('упоминания на чужих сайтах помечены как неизмеренные', d.geo.rows[1][1] === 'не измеряли');
@@ -82,7 +82,7 @@ ok('параметры нельзя поменять на ходу', Object.isFr
82
82
  audit.meta.lang = lang;
83
83
  const html = toHtml(audit, null);
84
84
  // Искать надо напечатанный элемент, а не название стиля: `.overall-num` есть в CSS всегда.
85
- ok(`${lang}: без балла заголовок не печатается вовсе`, !/<div class="overall-num/.test(html));
85
+ ok(`${lang}: без балла заголовок не печатается вовсе`, !/class="ring ring-/.test(html));
86
86
  ok(`${lang}: шесть областей отчёта на месте`, /second-measure/.test(html));
87
87
  }
88
88
  }
@@ -100,10 +100,11 @@ ok('параметры нельзя поменять на ходу', Object.isFr
100
100
  for (const [lang, grade] of [['en', /Strong|Workable|Weak|Poor|Critical/], ['ru', /Сильно|Рабочее состояние|Слабо|Плохо|Критично/]]) {
101
101
  audit.meta.lang = lang;
102
102
  const html = toHtml(audit, null);
103
- is(`${lang}: в отчёте стоит сохранённый балл`, Number((html.match(/overall-num[^>]*>(\d+)<span>/) || [])[1]), 61);
103
+ is(`${lang}: в отчёте стоит сохранённый балл`, Number((html.match(/class="ring-num"[^>]*>(\d+)</) || [])[1]), 61);
104
104
  ok(`${lang}: оценка словом на своём языке`, grade.test((html.match(/overall-grade">([^<]*)</) || [])[1] || ''));
105
105
  ok(`${lang}: сказано, что это то же измерение, что на странице`, /ai-visibility/.test((html.match(/overall-note">([^<]*)</) || [])[1] || ''));
106
- ok(`${lang}: пять областей напечатаны рядом с баллом`, /table class="areas"/.test(html));
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));
107
108
  const foot = (html.match(/scorecard-foot">([^<]*)</) || [])[1] || '';
108
109
  ok(`${lang}: сказано, что шесть областей не складываются в заголовок`, lang === 'en' ? /not a breakdown/.test(foot) : /\u043d\u0435 \u0440\u0430\u0437\u0431\u0438\u0432\u043a\u0430/.test(foot));
109
110
  }
@@ -146,7 +147,7 @@ ok('параметры нельзя поменять на ходу', Object.isFr
146
147
  ok('второго измерения не случилось', audit.overall.score === 63 && !audit.overall.error);
147
148
 
148
149
  audit.meta.lang = 'ru';
149
- is('и отчёт печатает именно его', Number((toHtml(audit, null).match(/overall-num[^>]*>(\d+)<span>/) || [])[1]), 63);
150
+ is('и отчёт печатает именно его', Number((toHtml(audit, null).match(/class="ring-num"[^>]*>(\d+)</) || [])[1]), 63);
150
151
  }
151
152
 
152
153
  /*
@@ -74,7 +74,9 @@ if (!dnsOk) {
74
74
  fixture.meta.lang = 'ru';
75
75
  fixture.overall = { score: v.score, grade: v.grade, areas: v.areas, source: 'visibility', basis: { pages: 1, sitemapRead: false, sitemapUnchecked: false } };
76
76
  const html = toHtml(fixture, null);
77
- ok('в таблице областей стоит слово, а не null', /<td><span>не измерялось<\/span><\/td>/.test(html) && !/null/.test(html.match(/<table class="areas">[\s\S]*?<\/table>/)[0]));
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));
78
80
  const note = (html.match(/overall-note">([^<]*)</) || [])[1] || '';
79
81
  ok('подпись говорит, сколько областей измерено', /Из пяти областей ниже измерены 4/.test(note));
80
82
  ok('и из скольких очков', new RegExp(`${sum} из 75`).test(note));
@@ -99,6 +99,7 @@ export const MESSAGES = {
99
99
  rulesUnknown: ' Its robots.txt could not be read either, so there is nothing to say about AI crawlers: that is not counted for or against the site.',
100
100
  howToCheckBots: ' To know for certain, open the server log and look for GPTBot, ClaudeBot and PerplexityBot, or ask whoever runs the protection which bots are on the allowlist.',
101
101
  area: { access: 'Can AI crawlers read it', index: 'Is there a map for agents (llms.txt)', entity: 'Is the entity clear (schema)', content: 'Is there something to quote', trust: 'Can it be dated and trusted' },
102
+ seo: { title: 'Title of 10 to 70 characters', description: 'Description of 50 to 170 characters', h1: 'An H1 on the page', canonical: 'A canonical address', og: 'An og:title for link previews', indexable: 'Not closed to indexing' },
102
103
  llmsUnreadable: (status) => `The site did not let this check read llms.txt${status ? ` (HTTP ${status})` : ''}, so whether the site has a map for agents could not be measured. It is not counted for or against the site: a file we were refused is not the same as a file that is not there.`,
103
104
  robotsMissing: 'There is no robots.txt on the site. By the standard that means nothing is disallowed, so every AI crawler may read it. A file is not required; add one only when something needs closing off.',
104
105
  sampleShort: (read, wanted) => `Only ${read} of ${wanted} pages answered in time, twice in a row, so there was not enough of the site to score. We do not show a number here: a score that goes up because we read less is a score nobody can defend. The site may simply be slow right now; run the check again in a minute.`,
@@ -150,6 +151,7 @@ export const MESSAGES = {
150
151
  rulesUnknown: ' Файл robots.txt прочитать тоже не вышло, поэтому про роботов ИИ сказать нечего, и в плюс или в минус сайту это не зачтено.',
151
152
  howToCheckBots: ' Чтобы знать точно, посмотрите в журнале сервера, приходят ли GPTBot, ClaudeBot и PerplexityBot, либо спросите у тех, кто настраивал защиту, кто у неё в белом списке.',
152
153
  area: { access: 'Могут ли роботы ИИ прочитать сайт', index: 'Есть ли карта для агентов (llms.txt)', entity: 'Понятно ли, кто вы (разметка)', content: 'Есть ли что процитировать', trust: 'Можно ли датировать и доверять' },
154
+ seo: { title: 'Заголовок от 10 до 70 знаков', description: 'Описание от 50 до 170 знаков', h1: 'На странице есть H1', canonical: 'Указан канонический адрес', og: 'Есть og:title для превью ссылки', indexable: 'Не закрыта от индексации' },
153
155
  llmsUnreadable: (status) => `Сайт не дал этой проверке прочитать llms.txt${status ? ` (код ${status})` : ''}, поэтому есть ли у сайта карта для агентов, измерить не вышло. В плюс или в минус это не зачтено: файл, который нам не отдали, это не то же самое, что файла нет.`,
154
156
  robotsMissing: 'Файла robots.txt на сайте нет. По стандарту это значит, что не запрещено ничего, то есть читать сайт может любой робот ИИ. Заводить файл необязательно: он нужен, только когда есть что закрывать.',
155
157
  sampleShort: (read, wanted) => `Из ${wanted} страниц ответили ${read}, и со второй попытки тоже, поэтому считать балл не по чему. Числа здесь не будет: балл, который растёт оттого, что мы прочитали меньше, защитить нельзя. Возможно, сайт сейчас просто медленный: запустите проверку через минуту.`,
@@ -402,6 +404,37 @@ export function engineReadiness(verdicts, parts, T, label = (v) => v.label) {
402
404
  });
403
405
  }
404
406
 
407
+
408
+ /**
409
+ * Основы SEO: второе число рядом с ИИ-видимостью.
410
+ *
411
+ * Простым языком. У большинства сайтов с обычным поиском всё в порядке, а для нейросетей они
412
+ * почти невидимы. Пока на экране одно число, этого разрыва не видно, и человек не понимает,
413
+ * почему ему вообще стоит волноваться. Два числа рядом объясняют это без слов: «для Google
414
+ * сайт в порядке, для ChatGPT нет».
415
+ *
416
+ * Считается ТОЛЬКО из того, что уже прочитано на тех же десяти страницах, ни одного нового
417
+ * запроса: заголовок, описание, H1, canonical, og:title и запрет индексации. Поэтому и
418
+ * называется «основы», а не «SEO»: полный аудит смотрит на скорость, ссылки и позиции, и этого
419
+ * здесь нет. Называть число громче, чем оно есть, это ровно то, за что мы критикуем конкурента.
420
+ *
421
+ * На балл ИИ-видимости не влияет ничем: это отдельное число, оно ни в плюс, ни в минус.
422
+ */
423
+ export function seoBasics(pages, T) {
424
+ const rules = [
425
+ { id: 'title', ok: (p) => p.title.length >= 10 && p.title.length <= 70 },
426
+ { id: 'description', ok: (p) => p.description.length >= 50 && p.description.length <= 170 },
427
+ { id: 'h1', ok: (p) => p.h1.length > 0 },
428
+ { id: 'canonical', ok: (p) => p.canonical.length > 0 },
429
+ { id: 'og', ok: (p) => p.ogTitle },
430
+ { id: 'indexable', ok: (p) => !/noindex/.test(p.robotsMeta) },
431
+ ];
432
+ const checks = rules.map((r) => ({ id: r.id, label: T.seo[r.id], passed: pages.filter(r.ok).length, total: pages.length }));
433
+ const passed = checks.reduce((a, c) => a + c.passed, 0);
434
+ const total = checks.reduce((a, c) => a + c.total, 0);
435
+ return { score: total ? Math.round((passed / total) * 100) : null, checks };
436
+ }
437
+
405
438
  async function readSitemap(origin, robotsText, timeoutFn = () => 3000) {
406
439
  const fromRobots = [...(robotsText || '').matchAll(/^sitemap:\s*(\S+)/gim)].map((m) => m[1]);
407
440
  const candidates = fromRobots.length
@@ -816,6 +849,7 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
816
849
  })),
817
850
  sitemap: { found: sitemap.found, count: sitemap.count, lastmod: sitemap.lastmod, unchecked: Boolean(!sitemap.found && (sitemap.timedOut || sitemap.skipped)) },
818
851
  crawlers: verdicts.map((v) => ({ label: agentLabel(v), kind: v.kind, verdict: v.verdict })),
852
+ seo: seoBasics(pages, T),
819
853
  engines: engineReadiness(verdicts, {
820
854
  access: null, // считается внутри по роботам каждого движка
821
855
  index: llmsRead ? index / 15 : null,