@operstack/audit 0.29.0 → 0.31.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.29.0",
3
+ "version": "0.31.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",
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: 'Что нашли',
@@ -167,7 +171,7 @@ function css() {
167
171
  :root { --ink:#14181c; --ink-2:#3b474d; --muted:#5c6b6f; --rule:#d8dedb; --paper:#fff; --panel:#f3f5f6; --accent:#0b7a75; --accent-soft:#dcefed; --gold:#8a6b38; --gold-soft:#f5efe3; --danger:#b3261e; --warn:#9a6700; --ok:#1f7a3d; --pad:16mm; }
168
172
  * { box-sizing: border-box; margin: 0; padding: 0; }
169
173
  body { font-family: "IBM Plex Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; color: var(--ink); background: #e9ecec; font-size: 10.2pt; line-height: 1.5; }
170
- .page { width: 210mm; min-height: 297mm; margin: 0 auto 8mm; background: var(--paper); padding: var(--pad) var(--pad) 22mm; position: relative; page-break-after: always; }
174
+ .page { width: 210mm; min-height: 297mm; margin: 0 auto 8mm; background: var(--paper); padding: var(--pad) var(--pad) 20mm; position: relative; page-break-after: always; }
171
175
  .page:last-child { page-break-after: auto; }
172
176
  h1, h2, h3 { font-family: "Fraunces", "Iowan Old Style", Georgia, serif; font-weight: 600; color: var(--ink); line-height: 1.15; }
173
177
  h2 { font-size: 19pt; margin-bottom: 4mm; padding-bottom: 2mm; border-bottom: 2px solid var(--accent-soft); }
@@ -182,6 +186,16 @@ function css() {
182
186
  .cover h1 { font-size: 34pt; margin: 2mm 0 4mm; }
183
187
  .cover-sub { font-size: 13pt; color: var(--ink-2); max-width: 85%; margin-bottom: 10mm; }
184
188
  .cover-url { display: inline-block; background: var(--accent-soft); color: var(--accent); padding: 2.5mm 4.5mm; border-radius: 4px; font-weight: 600; font-size: 11pt; }
189
+ /* Шапка первого листа вместо обложки: те же поля, но одной строкой и мелким кеглем. */
190
+ .masthead { border-top: 3px solid var(--accent); padding-top: 3.5mm; margin-bottom: 3.5mm; }
191
+ .masthead .cover-logo { max-height: 9mm; margin-bottom: 2.5mm; }
192
+ .mast-eyebrow { font-size: 7.5pt; letter-spacing: .12em; text-transform: uppercase; color: var(--accent); font-weight: 600; margin: 0 0 1.5mm; }
193
+ .mast-title { font-family: "Fraunces", Georgia, serif; font-size: 20pt; line-height: 1.08; margin: 0 0 2mm; font-weight: 600; }
194
+ .mast-sub { font-size: 9pt; color: var(--ink-2); line-height: 1.45; margin: 0 0 2.5mm; max-width: 150mm; }
195
+ .mast-meta { display: grid; grid-template-columns: repeat(4, auto); gap: 0 9mm; justify-content: start; border-top: 1px solid var(--rule); padding-top: 2.5mm; }
196
+ .mast-meta dt { font-size: 6.8pt; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin: 0 0 .6mm; }
197
+ .mast-meta dd { font-size: 8.5pt; font-weight: 600; margin: 0; }
198
+ .tool-line { font-size: 7.5pt; color: var(--muted); line-height: 1.4; margin: 4mm 0 0; }
185
199
  .cover-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; margin-top: 14mm; padding-top: 6mm; border-top: 1px solid var(--rule); }
186
200
  .cover-meta dt { font-size: 7.5pt; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-bottom: 1mm; }
187
201
  .cover-meta dd { font-size: 11pt; font-weight: 600; }
@@ -194,13 +208,52 @@ function css() {
194
208
  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
209
  table.areas td span { font-family: inherit; font-size: 9pt; color: var(--muted); font-weight: 400; }
196
210
  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; }
211
+ /*
212
+ * Сводка: кольцо, пилюли, полосы. Те же пороги цвета, что на странице результата: 80 и 45.
213
+ * Печать на бумагу, поэтому все размеры в миллиметрах и пунктах, а не в пикселях.
214
+ */
215
+ .overall { display: flex; align-items: center; gap: 7mm; border: 1px solid var(--rule); border-radius: 5px; padding: 3.5mm 6mm; background: #fafbfb; margin: 4mm 0 3mm; }
216
+ .ring { flex: none; color: var(--ok); }
217
+ .ring.ring-fair { color: #d9a030; } .ring.ring-poor { color: #d2694f; } .ring.ring-na { color: var(--muted); }
218
+ .ring-num { font-family: "Fraunces", Georgia, serif; font-size: 42px; font-weight: 600; fill: var(--ink); }
219
+ .ring-of { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 11px; letter-spacing: 1px; fill: var(--muted); }
220
+ .overall-side { min-width: 0; }
221
+ .pills { display: flex; flex-wrap: wrap; gap: 2mm; margin: 0 0 2.5mm; }
222
+ .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); }
223
+ .pill-fair { background: #fbf0d5; } .pill-poor { background: #f9e1db; } .pill-na { background: #eceef0; }
224
+ .pill b { font-family: "Fraunces", Georgia, serif; font-size: 12pt; letter-spacing: 0; color: var(--ok); }
225
+ .pill-fair b { color: #9a6700; } .pill-poor b { color: #b3261e; } .pill-na b { color: var(--muted); }
226
+ .pill-n { color: var(--ink-2); }
227
+ .overall-grade { font-family: "Fraunces", Georgia, serif; font-size: 14pt; font-weight: 600; margin: 0 0 1.5mm; }
228
+ .overall-note { font-size: 8.5pt; color: var(--muted); line-height: 1.45; margin: 0; }
229
+
230
+ h3.engines-head, h3.areas-head { font-size: 11pt; margin: 4.5mm 0 2.2mm; }
231
+ .engines { display: grid; grid-template-columns: repeat(5, 1fr); gap: 2.2mm; }
232
+ .eng { border: 1px solid var(--rule); border-radius: 4px; padding: 2.4mm 2.6mm; background: #fafbfb; }
233
+ .eng p { margin: 0; }
234
+ .eng-name { font-size: 8pt; font-weight: 600; }
235
+ .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; }
236
+ .eng-fair .eng-num { color: #9a6700; } .eng-poor .eng-num { color: #b3261e; } .eng-na .eng-num { color: var(--muted); }
237
+ .eng-how { font-size: 6.2pt; color: var(--muted); line-height: 1.3; margin-top: 1.4mm; }
238
+ .eng-poor .eng-how { color: #b3261e; }
239
+
240
+ .pbar { height: 1.3mm; border-radius: 1mm; background: #ecefee; overflow: hidden; }
241
+ .pbar i { display: block; height: 100%; background: var(--ok); }
242
+ .eng-fair .pbar i, .ar-fair .pbar i { background: #e8b84a; }
243
+ .eng-poor .pbar i, .ar-poor .pbar i { background: #d2694f; }
244
+ .eng-na .pbar i, .ar-na .pbar i { background: var(--muted); }
245
+
246
+ .areas-bars { margin-bottom: 2mm; }
247
+ .ar { padding: 2mm 0; border-top: 1px solid var(--rule); }
248
+ .ar:first-child { border-top: 0; padding-top: 0.5mm; }
249
+ .ar-h { display: grid; grid-template-columns: 1fr auto auto; gap: 3mm; align-items: baseline; margin-bottom: 1.4mm; }
250
+ .ar-n { font-size: 9pt; font-weight: 600; }
251
+ .ar-w { font-size: 7pt; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); }
252
+ .ar-v { font-family: "Fraunces", Georgia, serif; font-size: 13pt; color: var(--ok); font-variant-numeric: tabular-nums; min-width: 14mm; text-align: right; }
253
+ .ar-fair .ar-v { color: #9a6700; } .ar-poor .ar-v { color: #b3261e; }
254
+ .ar-v span { font-size: 8pt; color: var(--muted); }
255
+ .ar-v .na { font-family: inherit; font-size: 8pt; color: var(--muted); }
256
+
204
257
  .scorecard { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin: 5mm 0 6mm; }
205
258
  .score { border: 1px solid var(--rule); border-radius: 4px; padding: 3.5mm 4mm; background: #fafbfb; }
206
259
  .score .label { font-size: 8pt; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 1.5mm; }
@@ -264,12 +317,44 @@ export function toHtml(audit, brand = null) {
264
317
  * заголовком не нужно и не получится, и об этом сказано словами.
265
318
  */
266
319
  const head = overallSummary(a.overall, { lang: a.meta?.lang === 'ru' ? 'ru' : 'en' });
320
+ /*
321
+ * Сводка на языке, которым с 17.09.2026 говорит страница результата: кольцо со шкалой, две
322
+ * пилюли с числами, полосы по областям, цвет по значению. До этого здесь были плоские числа в
323
+ * таблице, и владелец сказал прямо, что бесплатная страница выглядит лучше платного отчёта.
324
+ *
325
+ * Цвет считается одним правилом на весь отчёт, теми же порогами, что у буквы: 80 и 45.
326
+ */
327
+ const tone = (pct) => (pct === null ? 'na' : pct >= 80 ? 'good' : pct >= 45 ? 'fair' : 'poor');
328
+ const RING_R = 58;
329
+ const RING_C = 2 * Math.PI * RING_R;
330
+ const ring = (score) => `<svg class="ring ring-${tone(score)}" viewBox="0 0 148 148" width="104" height="104" role="img" aria-label="${score} ${esc(L.ofHundred || 'of 100')}">
331
+ <circle cx="74" cy="74" r="${RING_R}" fill="none" stroke="#ECE9E2" stroke-width="12"/>
332
+ <circle cx="74" cy="74" r="${RING_R}" fill="none" stroke="currentColor" stroke-width="12" stroke-linecap="round"
333
+ stroke-dasharray="${RING_C.toFixed(1)}" stroke-dashoffset="${(RING_C * (1 - score / 100)).toFixed(1)}" transform="rotate(-90 74 74)"/>
334
+ <text class="ring-num" x="74" y="80" text-anchor="middle">${score}</text>
335
+ <text class="ring-of" x="74" y="101" text-anchor="middle">${esc(L.ofHundredShort || 'OF 100')}</text>
336
+ </svg>`;
337
+ const grade100 = (n) => (n >= 80 ? 'A' : n >= 65 ? 'B' : n >= 45 ? 'C' : n >= 25 ? 'D' : 'E');
338
+ 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>`;
339
+ const seo = a.overall && a.overall.seo && typeof a.overall.seo.score === 'number' ? a.overall.seo : null;
340
+ const engines = Array.isArray(a.overall && a.overall.engines) ? a.overall.engines : [];
341
+
267
342
  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>` : '');
343
+ ${ring(head.score)}
344
+ <div class="overall-side">
345
+ <p class="pills">${pill(L.aiVisibility || 'AI visibility', head.score)}${seo ? pill(L.seoBasics || 'SEO basics', seo.score) : ''}</p>
346
+ <div class="overall-grade">${esc(head.grade)}</div>
347
+ <p class="overall-note">${esc(head.note)}</p>
348
+ </div>
349
+ </div>`
350
+ + (engines.length ? `<h3 class="engines-head">${esc(L.enginesHead || 'Readiness by engine')}</h3><div class="engines">${engines.map((e) => {
351
+ const v = e.score === null ? 0 : e.score;
352
+ 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>`;
353
+ }).join('')}</div>` : '')
354
+ + (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) => {
355
+ const pct = x.score === null || !x.max ? null : Math.round((x.score / x.max) * 100);
356
+ 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>`;
357
+ }).join('')}</div>` : '');
273
358
  const scoreCards = Object.entries(measured).map(([label, n]) => {
274
359
  const basis = scoreBasis[label] || {};
275
360
  const shown = (L.areas && L.areas[label]) || label;
@@ -283,32 +368,116 @@ export function toHtml(audit, brand = null) {
283
368
  const pages = [];
284
369
  const logo = brand && brand.logo ? `<img class="cover-logo" src="${brand.logo}" alt="">` : '';
285
370
  const preparedBy = (brand && brand.preparedBy) || a.client.preparedBy || 'OperStack';
286
- const toolLine = !brand || brand.showToolLine !== false
287
- ? `<div class="cover-foot">${esc(L.collectedBy(a.meta.tool, (a.meta.collectedAt || '').slice(0, 10)))}</div>`
288
- : `<div class="cover-foot">${esc(L.reproducible)}</div>`;
289
- pages.push(`<div class="page"><div class="cover"><div><div class="cover-top">${logo}<div class="eyebrow">${L.eyebrowAudit}</div><h1>${L.coverTitle}</h1><p class="cover-sub">${t(a.client.subject)}</p><span class="cover-url">${esc(a.meta.host)}</span></div>
290
- <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
- ${toolLine}</div></div>`);
292
- 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>`);
371
+ /*
372
+ * Обложки отдельным листом больше нет: заголовок уменьшен и стоит шапкой первого листа.
373
+ *
374
+ * Лист, на котором только название и четыре поля, в отчёте на двенадцать страниц никто не
375
+ * читает. Замерено перед правкой: содержимое ровно влезает в лист, перелива ноль. Строка про
376
+ * предмет обрезается по длине, иначе длинное описание чужого сайта вытолкнет низ листа на
377
+ * следующую страницу, и мы вернёмся к тому, от чего уходили.
378
+ */
379
+ const subjectText = t(a.client.subject);
380
+ const subjectShort = subjectText.length > 190 ? `${subjectText.slice(0, 187).trimEnd()}\u2026` : subjectText;
381
+ const masthead = `<div class="masthead">${logo}<p class="mast-eyebrow">${L.eyebrowAudit}</p><h1 class="mast-title">${L.coverTitle.replace(/<br>/g, ' ')}</h1><p class="mast-sub">${subjectShort}</p><dl class="mast-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>`;
382
+ /*
383
+ * Сводка занимает ДВА листа намеренно, а не один с переливом.
384
+ *
385
+ * Раньше всё лежало на одной странице, не влезало, и хвост уезжал на свой лист: три строчки
386
+ * приоритетов и восемьдесят процентов белого. Таких листов в отчёте было три из двенадцати, и
387
+ * выглядело это как недоделанная работа. Теперь первый лист про балл и из чего он сложился,
388
+ * второй про проверки самого отчёта и что делать. Оба полные.
389
+ */
390
+ pages.push(`<div class="page">${masthead}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p>${overallBlock}${footer(a, n++, preparedBy)}</div>`);
391
+ 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>`);
392
+ /*
393
+ * Обзор: таблица прочитанных страниц не влезала на лист и переливалась. Режем её по счёту
394
+ * строк, как техническую. Первый лист несёт параметры сайта и первые строки, дальше только
395
+ * продолжение таблицы со своей шапкой.
396
+ */
397
+ const sampleRows = (a.sample || []).filter((x) => x.title !== undefined).slice(0, 20);
398
+ const pagesHead = `<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>`;
399
+ const pageRow = (x) => `<tr><td><code>${esc(new URL(x.url).pathname)}</code></td><td>${esc(x.title)}</td><td>${x.words}</td><td>${x.h2Count ?? '\u00b7'}</td><td>${x.tables ?? '\u00b7'}</td><td>${x.linkCount ?? '\u00b7'}</td><td>${x.images ? `${x.images - x.imagesNoAlt}/${x.images}` : '\u00b7'}</td></tr>`;
400
+ const FIRST_PAGE_ROWS = 7;
401
+ const NEXT_PAGE_ROWS = 16;
402
+ const firstRows = sampleRows.slice(0, FIRST_PAGE_ROWS);
403
+ const restRows = sampleRows.slice(FIRST_PAGE_ROWS);
404
+ 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">${pagesHead}${firstRows.map(pageRow).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
405
+ for (let i = 0; i < restRows.length; i += NEXT_PAGE_ROWS) {
406
+ const chunk = restRows.slice(i, i + NEXT_PAGE_ROWS);
407
+ pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${L.overviewEyebrow}</div><h2>${L.pagesSampled}</h2><table class="pages">${pagesHead}${chunk.map(pageRow).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
408
+ }
295
409
  const cardBody = (c) => [
296
410
  c.text ? `<p>${t(c.text)}</p>` : '',
297
411
  c.cost ? `<p><span class="card-tag">${L.whatItCosts}</span>${t(c.cost)}</p>` : '',
298
412
  c.fix ? `<p><span class="card-tag">${L.theFix}</span>${t(c.fix)}</p>` : '',
299
413
  ].join('') || '<p class="card-empty">${L.noBody}</p>';
300
414
  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>`);
415
+ /*
416
+ * Лист «что чинить первым» печатается, только когда есть что чинить. Раньше на чистом сайте
417
+ * он занимал целую страницу одной строкой «критичных дефектов не найдено», а это в отчёте за
418
+ * деньги выглядит как пустой лист. Сам факт не пропадает: он сказан в сводке.
419
+ */
420
+ if ((a.critical || []).length) {
421
+ 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>`);
422
+ }
423
+ /*
424
+ * Длинная таблица режется на листы по счёту строк, а не переливается сама.
425
+ *
426
+ * Перелив оставлял лист с четырьмя строками и восемьюдесятью процентами белого: в отчёте за
427
+ * деньги это читается как недоделанная работа. Порог взят по факту: строка занимает примерно
428
+ * 11 мм, на лист влезает около восемнадцати вместе с заголовком. Строка с пояснением выше,
429
+ * поэтому считаем её за полторы.
430
+ */
431
+ const techChecks = [...checksBy('technical'), ...checksBy('onpage')];
432
+ const techHead = `<tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>`;
433
+ 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>`;
434
+ const TECH_UNITS_PER_PAGE = 17;
435
+ const techPages = [];
436
+ let bucket = [];
437
+ let units = 0;
438
+ for (const c of techChecks) {
439
+ const cost = c.comment ? 1.5 : 1;
440
+ if (units + cost > TECH_UNITS_PER_PAGE && bucket.length) { techPages.push(bucket); bucket = []; units = 0; }
441
+ bucket.push(c); units += cost;
442
+ }
443
+ if (bucket.length) techPages.push(bucket);
444
+ techPages.forEach((chunk, i) => {
445
+ const title = techPages.length > 1 ? `${L.technicalTitle} (${i + 1}/${techPages.length})` : L.technicalTitle;
446
+ 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>`);
447
+ });
304
448
  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
449
  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
- 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>`);
307
- const checkTable = (g) => (checksBy(g).length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy(g).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('')}</table>` : '');
308
450
  const sources = [...(a.sources || []), ...(a.demand && Array.isArray(a.demand.queries) ? [demandSourceRow(a.demand, a.meta?.lang === 'ru' ? 'ru' : 'en')] : [])];
309
- pages.push(`<div class="page">${header(a, L.secOffpage, L)}<div class="eyebrow">${L.offpageEyebrow}</div><h2>${L.offpageTitle}</h2>${checkTable('offpage')}${list(a.offpage.listed)}<p>${t(a.offpage.note)}</p><div class="eyebrow" style="margin-top:8mm">${L.conversionEyebrow}</div><h2>${L.conversionTitle}</h2>${checkTable('conversion')}<table><tr><th>${L.colElement}</th><th>${L.colStatus}</th></tr>${(a.conversion.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><div class="eyebrow" style="margin-top:8mm">${L.limitationsEyebrow}</div><h2>${L.sourcesTitle}</h2>${sources.length ? `<table><tr><th>${L.colSource}</th><th>${L.colAccess}</th><th>${L.colWhatRead}</th></tr>${sources.map((s) => `<tr><td>${t(s.name)}</td><td>${t(s.access)}</td><td>${t(s.detail)}</td></tr>`).join('')}</table><p class="scorecard-foot">${L.sourcesFoot}</p>` : ''}<h3>${L.limitations}</h3>${list(a.limitations)}${footer(a, n++, preparedBy)}</div>`);
310
- const phases = (a.roadmap || []).map((p) => `<div class="phase"><div class="phase-head"><span class="badge">${esc(p.badge)}</span><span class="phase-title">${t(p.title)}</span></div>${olist(p.items)}</div>`).join('');
311
- pages.push(`<div class="page">${header(a, L.secRoadmap, L)}<div class="eyebrow">${L.roadmapEyebrow}</div><h2>${L.roadmapTitle}</h2>${phases}<div class="verdict"><p><strong>${L.keyMessage}</strong> ${t(a.closing)}</p></div>${footer(a, n++, preparedBy)}</div>`);
451
+ const checkTable = (g) => (checksBy(g).length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy(g).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('')}</table>` : '');
452
+ /*
453
+ * AEO и GEO стояли на одном листе: два заголовка, два списка, две врезки и таблица сигналов.
454
+ * Лист переливал почти на восемьсот пикселей. Теперь по разделу на лист.
455
+ */
456
+ pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${L.aeoTitle}</h2><p class="lead">${esc(L.aeoLead)}</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>${footer(a, n++, preparedBy)}</div>`);
457
+ /*
458
+ * Таблица сигналов GEO выросла до двадцати пяти строк и на лист не влезает. Режем её, как и
459
+ * остальные длинные таблицы: по счёту строк, с продолжением под своим номером.
460
+ */
461
+ const geoRowList = [...checksBy('geo'), ...checksBy('overview')].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>`);
462
+ const geoHead = `<tr><th>${L.colSignal}</th><th>${L.colStatus}</th><th>${L.colDetail}</th></tr>`;
463
+ const GEO_ROWS_PER_PAGE = 15;
464
+ const geoChunks = [];
465
+ for (let i = 0; i < geoRowList.length; i += GEO_ROWS_PER_PAGE) geoChunks.push(geoRowList.slice(i, i + GEO_ROWS_PER_PAGE));
466
+ (geoChunks.length ? geoChunks : [[]]).forEach((chunk, i, all) => {
467
+ const title = all.length > 1 ? `${L.geoTitle} (${i + 1}/${all.length})` : L.geoTitle;
468
+ const tail = i === all.length - 1 ? `<div class="callout"><p>${t(a.geo.callout)}</p></div>` : '';
469
+ pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${esc(title)}</h2><table>${geoHead}${chunk.join('')}</table>${tail}${footer(a, n++, preparedBy)}</div>`);
470
+ });
471
+ pages.push(`<div class="page">${header(a, L.secOffpage, L)}<div class="eyebrow">${L.offpageEyebrow}</div><h2>${L.offpageTitle}</h2>${checkTable('offpage')}${list(a.offpage.listed)}<p>${t(a.offpage.note)}</p>${footer(a, n++, preparedBy)}</div>`);
472
+ pages.push(`<div class="page">${header(a, L.secOffpage, L)}<div class="eyebrow">${L.conversionEyebrow}</div><h2>${L.conversionTitle}</h2>${checkTable('conversion')}<table><tr><th>${L.colElement}</th><th>${L.colStatus}</th></tr>${(a.conversion.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
473
+
474
+ const phases = (a.roadmap || []).map((x) => `<div class="phase"><div class="phase-head"><span class="badge">${esc(x.badge)}</span><span class="phase-title">${t(x.title)}</span></div>${olist(x.items)}</div>`).join('');
475
+ /*
476
+ * Последний лист несёт и дорожную карту, и источники с границами замера. Оба раздела короткие,
477
+ * по отдельности каждый занимал меньше половины листа, и владелец просил ровно двенадцать.
478
+ * Проверено замером: вместе влезают без перелива.
479
+ */
480
+ pages.push(`<div class="page">${header(a, L.secRoadmap, L)}<div class="eyebrow">${L.roadmapEyebrow}</div><h2>${L.roadmapTitle}</h2>${phases}<div class="verdict"><p><strong>${L.keyMessage}</strong> ${t(a.closing)}</p></div><div class="eyebrow" style="margin-top:7mm">${L.limitationsEyebrow}</div><h2>${L.sourcesTitle}</h2>${sources.length ? `<table><tr><th>${L.colSource}</th><th>${L.colAccess}</th><th>${L.colWhatRead}</th></tr>${sources.map((x) => `<tr><td>${t(x.name)}</td><td>${t(x.access)}</td><td>${t(x.detail)}</td></tr>`).join('')}</table><p class="scorecard-foot">${L.sourcesFoot}</p>` : ''}<h3>${L.limitations}</h3>${list(a.limitations)}<p class="tool-line">${esc(brand && brand.showToolLine === false ? L.reproducible : L.collectedBy(a.meta.tool, (a.meta.collectedAt || '').slice(0, 10)))}</p>${footer(a, n++, preparedBy)}</div>`);
312
481
  // Карта спроса идёт приложением после дорожной карты: она не входит в баллы, и об этом
313
482
  // сказано в ней самой и в таблице источников выше.
314
483
  if (a.demand && Array.isArray(a.demand.queries)) {
@@ -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));