@operstack/audit 0.35.0 → 0.36.2
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 +2 -2
- package/src/collect.mjs +106 -24
- package/src/etalon.mjs +1 -1
- package/src/fix.mjs +1 -1
- package/src/i18n.mjs +41 -25
- package/src/index.mjs +1 -0
- package/src/narrative.mjs +80 -32
- package/src/pagekind.mjs +6 -0
- package/src/presentation.mjs +95 -0
- package/src/prompts.mjs +57 -15
- package/src/render.mjs +41 -25
- package/src/rendered.mjs +22 -3
- package/src/review.mjs +15 -7
- package/src/tasks-pdf.mjs +1 -1
- package/src/test-answer-first.mjs +27 -0
- package/src/test-demand.mjs +3 -3
- package/src/test-i18n.mjs +3 -3
- package/src/test-narrative.mjs +1 -1
- package/src/test-overall.mjs +1 -1
- package/src/test-planted.mjs +15 -0
- package/src/test-presentation.mjs +67 -0
- package/src/test-rendered.mjs +11 -0
- package/src/test-shopify-store.mjs +1 -1
- package/src/verify.mjs +30 -15
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Проверка оформления отчёта кодом перед сборкой PDF (14.10.2026, задание «проверка глазами»).
|
|
3
|
+
*
|
|
4
|
+
* Простым языком. Проверка фактов ловит неправду, но не ловит сырой вид: пустой раздел плана,
|
|
5
|
+
* «Сейчас: 1: t.me», фразу, оборванную на предлоге, «Норма» при 1 из 20, «Тонкие страницы» в сильных
|
|
6
|
+
* сторонах. Отчёт cimes.ru ушёл бы клиенту таким. Здесь восемь правил, каждое смотрит готовый текст
|
|
7
|
+
* отчёта и задач. Хоть одна находка: отчёт не собирается и не уходит, причина пишется словами.
|
|
8
|
+
* Модель не нужна, денег это не стоит.
|
|
9
|
+
*/
|
|
10
|
+
import { statementsOf } from './review.mjs';
|
|
11
|
+
import { agentPrompts } from './prompts.mjs';
|
|
12
|
+
import { localiseChecks } from './i18n.mjs';
|
|
13
|
+
|
|
14
|
+
/** Пороги в одном месте: «Норма» у строки «X из N» невозможна, если X меньше половины N и меньше трёх. */
|
|
15
|
+
export const STATUS_OK_SHARE = 0.5;
|
|
16
|
+
export const STATUS_OK_MIN = 3;
|
|
17
|
+
|
|
18
|
+
/** Строки, где «X из N» считает страницы, на которых проблема: X это сколько страниц должна назвать задача. */
|
|
19
|
+
export const PROBLEM_SHARE = new Set(['sections', 'answer-first']);
|
|
20
|
+
/** Строки, где «X из N» считает страницы, на которых всё хорошо: чем больше X, тем лучше. */
|
|
21
|
+
export const GOOD_SHARE = new Set(['dates', 'conv-cta', 'schema-all', 'sources', 'sitemap-health', 'title-all', 'description-all', 'canonical-all', 'og-all', 'faq-visible']);
|
|
22
|
+
|
|
23
|
+
const SERVICE = [
|
|
24
|
+
[/(^|[\s:(])\d+: [a-z0-9-]+\.[a-z]{2,}(?=[\s,.;)]|$)/i, 'служебное значение вида «1: t.me»'],
|
|
25
|
+
[/\bundefined\b/, '«undefined»'],
|
|
26
|
+
[/(?<!\/dev\/)\bnull\b/, '«null»'],
|
|
27
|
+
[/\bNaN\b/, '«NaN»'],
|
|
28
|
+
[/\[object /, '«[object …]»'],
|
|
29
|
+
[/\{\{|\}\}|(^|\s)[{}](\s|$)/, 'фигурная скобка шаблона'],
|
|
30
|
+
[/\b(chk|task|sum|sub|ov|road|cont|aeo|geo|conv|off):[a-z][\w-]*/, 'голый ключ вида chk:…'],
|
|
31
|
+
];
|
|
32
|
+
// Конец на предлоге или союзе, либо «на 1» без существительного («та же дата видна на самой странице на 1», cimes.ru).
|
|
33
|
+
const DANGLING = /(?:^|\s)(на|в|во|из|с|со|к|ко|по|о|об|от|до|для|при|про|у|и|или|а|но|что|of|on|in|at|to|for|and|or|the|with|from|by)$|(?:^|\s)(?:на|у|по|on) \d+\.?$/i;
|
|
34
|
+
const NEGATIVE_LABEL = /тонк|короткие|ошибк|битые|битых|пуст|дубл|нет |не найден|thin|broken|missing|duplicate|empty|error/i;
|
|
35
|
+
const QUOTED = /«[^»]*»|"[^"]*"|“[^”]*”/g;
|
|
36
|
+
/** Поля, где стоит текст с сайта клиента: тире там его, не наше. */
|
|
37
|
+
const SITE_TEXT = (id) => id === 'sub:subject' || /^ov:row:/.test(id) || id === 'chk:schema:value';
|
|
38
|
+
|
|
39
|
+
const pagePaths = (t) => [...new Set((String(t).match(/(?<![\w.])\/[\w%.-]+(?:\/[\w%.-]+)*\/?/g) || []).filter((p) => p.length > 1))];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @returns {{ ok: boolean, problems: { rule: number, id: string, why: string, text: string }[] }}
|
|
43
|
+
*/
|
|
44
|
+
export function presentationCheck(audit, { lang = audit.meta?.lang || 'en' } = {}) {
|
|
45
|
+
const ru = lang === 'ru';
|
|
46
|
+
const problems = [];
|
|
47
|
+
const add = (rule, id, why, text = '') => problems.push({ rule, id, why, text: String(text).slice(0, 200) });
|
|
48
|
+
const st = statementsOf(audit, { lang });
|
|
49
|
+
const tasks = agentPrompts(audit, { lang });
|
|
50
|
+
|
|
51
|
+
// 1. Пустой раздел или заголовок без строк.
|
|
52
|
+
for (const [i, ph] of (audit.roadmap || []).entries()) if (!(ph.items || []).filter((x) => String(x).trim()).length) add(1, `road:${i}`, `пустой раздел плана «${ph.badge}»`);
|
|
53
|
+
if (!(audit.summary?.priorities || []).filter((x) => String(x).trim()).length) add(1, 'sum:prio', 'нет ни одного приоритета');
|
|
54
|
+
for (const t of tasks) if (!/\S/.test(String(t.text).replace(/\*\*[^*]+\*\*/g, '').replace(/[#\s]/g, ''))) add(1, `task:${t.id}`, 'пустая задача');
|
|
55
|
+
|
|
56
|
+
// 2. Служебные значения. 3. Оборванная фраза. 8. Тире в наших фразах.
|
|
57
|
+
const texts = [...st.map((s) => ({ id: s.id, text: s.text })), ...tasks.map((t) => ({ id: `task:${t.id}`, text: t.text }))];
|
|
58
|
+
for (const { id, text } of texts) {
|
|
59
|
+
// Команды для разработчика (curl, текст в обратных кавычках) не наш текст для владельца: «/dev/null» там на месте.
|
|
60
|
+
const own = String(text).replace(/`[^`]*`/g, ' ').replace(/\bcurl\b[^)\n]*/g, ' ').replace(QUOTED, ' ');
|
|
61
|
+
for (const [re, why] of SERVICE) if (re.test(own)) add(2, id, why, text);
|
|
62
|
+
for (const line of String(text).split('\n')) { const l = line.replace(/\*\*/g, '').trim(); if (l && DANGLING.test(l)) add(3, id, 'фраза оборвана на предлоге или союзе', l); }
|
|
63
|
+
if (!SITE_TEXT(id) && /[—–]/.test(own)) add(8, id, 'длинное или среднее тире в нашем тексте', text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 4. Статус противоречит числу. 7. Число в задаче не совпадает с отчётом.
|
|
67
|
+
const shown = ru ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
|
|
68
|
+
for (const c of shown) {
|
|
69
|
+
if (!GOOD_SHARE.has(c.id) && !PROBLEM_SHARE.has(c.id)) continue;
|
|
70
|
+
const m = String(c.value || '').match(/(\d+) (?:из|of) (\d+)/);
|
|
71
|
+
if (!m) continue;
|
|
72
|
+
let [x, n] = [Number(m[1]), Number(m[2])];
|
|
73
|
+
// Английская строка «15 of 20 carry three or more H2» считает хорошие страницы: проблемных n - x.
|
|
74
|
+
if (PROBLEM_SHARE.has(c.id) && /three or more|три и более|open with|есть на всех/i.test(String(c.value))) x = n - x;
|
|
75
|
+
if (GOOD_SHARE.has(c.id) && c.status === 'ok' && n && x / n < STATUS_OK_SHARE && x < STATUS_OK_MIN) add(4, `chk:${c.id}`, `«Норма» при ${x} из ${n}`, `${c.label}: ${c.value}`);
|
|
76
|
+
const t = tasks.find((k) => k.id === c.id);
|
|
77
|
+
if (t) {
|
|
78
|
+
const listed = pagePaths(String(t.text).split(/\*\*(?:Как проверить|How to check):\*\*/)[0].replace(/\*\*(?:Сейчас|Now):\*\*[^\n]*/, ''));
|
|
79
|
+
const need = PROBLEM_SHARE.has(c.id) ? x : n - x;
|
|
80
|
+
if (listed.length && listed.length < Math.min(need, 5) && !/вошли только|вошли страницы|only the long|остальные|the others|есть только на|is on only/i.test(t.text)) add(7, `task:${c.id}`, `в отчёте ${need} страниц, в задаче названо ${listed.length}, правило отбора не объяснено`, t.text);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 5. Отрицательное название в сильных сторонах.
|
|
85
|
+
for (const [i, x] of (audit.content?.strengths || []).entries()) { const label = String(x).split(':')[0]; if (NEGATIVE_LABEL.test(label)) add(5, `cont:str:${i}`, `в сильных сторонах название с отрицательным смыслом: «${label}»`, x); }
|
|
86
|
+
|
|
87
|
+
// 6. «Не измеряли» там, где данные есть.
|
|
88
|
+
const ov = audit.overview?.rows || [];
|
|
89
|
+
const hasCheck = (id) => (audit.checks || []).some((c) => c.id === id && c.value);
|
|
90
|
+
for (const [i, [k, v]] of ov.entries()) {
|
|
91
|
+
if (!/не измеряли|not measured/i.test(String(v))) continue;
|
|
92
|
+
if ((/движок|cms|engine/i.test(k) && hasCheck('cms')) || (/карт|sitemap/i.test(k) && hasCheck('sitemap')) || (/язык|language/i.test(k) && audit.meta?.language)) add(6, `ov:row:${i}`, `«не измеряли» в строке «${k}», хотя данные собраны`);
|
|
93
|
+
}
|
|
94
|
+
return { ok: problems.length === 0, problems };
|
|
95
|
+
}
|
package/src/prompts.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Заданий с собственным текстом столько, сколько проверок мы умеем чинить осмысленно. Для
|
|
15
15
|
* остальных собирается общее задание из описания работы: оно короче, но не врёт.
|
|
16
16
|
*/
|
|
17
|
+
import { isListPage } from './pagekind.mjs';
|
|
17
18
|
import { FIX_ACTIONS } from './fix.mjs';
|
|
18
19
|
import { localiseChecks } from './i18n.mjs';
|
|
19
20
|
import { vocabFor } from './niche.mjs';
|
|
@@ -222,7 +223,7 @@ const P = {
|
|
|
222
223
|
en: { task: 'Ask the owner where the business really has profiles: social networks, directories, maps, industry sites. Put those addresses in the sameAs field of the organisation markup and link them from the footer. Do not add profiles that do not exist or are abandoned.', verify: 'The organisation JSON-LD has sameAs with at least two addresses, and each opens a profile of this very business.' },
|
|
223
224
|
},
|
|
224
225
|
'trust-about': {
|
|
225
|
-
ru: { task: 'Сделай страницу «О компании» (или «Команда») и поставь на неё ссылку в меню или подвал, чтобы она была видна со всех страниц. На странице: кто стоит за сайтом, чем занимается, с какого года, где находится, как связаться. Всё только со слов владельца или из того, что уже опубликовано; если страница уже есть, просто добавь ссылку.', verify: '
|
|
226
|
+
ru: { task: 'Сделай страницу «О компании» (или «Команда») и поставь на неё ссылку в меню или подвал, чтобы она была видна со всех страниц. На странице: кто стоит за сайтом, чем занимается, с какого года, где находится, как связаться. Всё только со слов владельца или из того, что уже опубликовано; если страница уже есть, просто добавь ссылку.', verify: 'Ссылка на страницу о компании стоит в меню или подвале каждой страницы сайта, и страница открывается с кодом 200.' },
|
|
226
227
|
en: { task: 'Make an About (or Team) page and link it from the menu or footer so it is reachable from every page. On it: who is behind the site, what they do, since when, where, how to get in touch. Use only what the owner tells you or what is already published; if the page exists, just add the link.', verify: 'Every sampled page links to the about page, and it opens with a 200.' },
|
|
227
228
|
},
|
|
228
229
|
'trust-policies': {
|
|
@@ -275,9 +276,19 @@ const PF = {
|
|
|
275
276
|
title: ({ ru, v }) => (ru
|
|
276
277
|
? { task: 'Перепиши заголовок главной (тег title): до 60 знаков, словами, которыми ищут ' + v.people + ', что это за компания и чем она занимается, название компании в конце. Не повторяй слова и не пиши список ключевых слов через запятую.', verify: 'Заголовок главной от 30 до 60 знаков, в нём названо, чем занимается компания, и он отличается от заголовков других страниц.' }
|
|
277
278
|
: { task: 'Rewrite the home page title: up to 60 characters, in the words ' + v.people + ' search with, saying what the business is and does, company name at the end. No repeated words, no keyword lists.', verify: 'The home page title is 30 to 60 characters, says what the business does, and differs from other page titles.' }),
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
279
|
+
// Дат мало: называем, где они есть, и на каких страницах их ставить; «сверь даты» исполнителю ничего не говорит (cimes.ru, 14.10.2026).
|
|
280
|
+
dates: ({ audit, ru }) => {
|
|
281
|
+
const sm = sampleOf(audit); const dated = sm.filter((p) => p.dates && (p.dates.published || p.dates.modified));
|
|
282
|
+
const c = (audit?.checks || []).find((x) => x.id === 'dates');
|
|
283
|
+
if (c && !/precede/.test(String(c.value)) && dated.length < sm.length / 2) {
|
|
284
|
+
const list = dated.slice(0, 5).map((p) => pathOf(p.url)).join(', ');
|
|
285
|
+
return ru
|
|
286
|
+
? { task: `Дата публикации в разметке (datePublished) есть только на ${dated.length} из ${sm.length} страниц выборки${list ? `: ${list}` : ''}. Добавь её и видимую строку «Опубликовано: число» на страницы, у которых правда есть дата: статьи блога, новости, кейсы портфолио. Страницам услуг и контактов дата не нужна, не ставь её туда.`, verify: 'На статьях, новостях и кейсах в разметке есть datePublished, и та же дата видна на самой странице.' }
|
|
287
|
+
: { task: `A published date in the markup (datePublished) is on only ${dated.length} of ${sm.length} sampled pages${list ? `: ${list}` : ''}. Add it, with a visible "Published: date" line, to pages that really have a date: blog posts, news, portfolio cases. Service and contact pages need none.`, verify: 'Posts, news and cases carry datePublished in the markup, and the same date is visible on the page.' };
|
|
288
|
+
}
|
|
289
|
+
return datesGeneric({ ru });
|
|
290
|
+
},
|
|
291
|
+
|
|
281
292
|
'trust-entity': ({ ru }) => (ru
|
|
282
293
|
? { task: 'После того как появится разметка организации (задача про неё выше), допиши в неё адрес: город и страну (PostalAddress с addressLocality и addressCountry). Адрес головного офиса спроси у владельца, если на сайте его нет; адреса филиалов бери со страниц сайта.', verify: 'В разметке организации есть город и страна, и они совпадают с адресом, который подтвердил владелец.' }
|
|
283
294
|
: { task: 'Once the organisation markup exists (task above), add the address: city and country (PostalAddress with addressLocality and addressCountry). Ask the owner for the head office address if the site does not show it.', verify: 'The organisation markup carries city and country, matching the address the owner confirmed.' }),
|
|
@@ -326,17 +337,40 @@ const PF = {
|
|
|
326
337
|
'faq-schema': ({ ru, v }) => (ru
|
|
327
338
|
? { task: `Собери с владельцем 5-7 частых вопросов ${v.peopleGen} с ответами, опубликуй их видимым блоком на страницах главных услуг и только потом разметь схемой FAQPage. Текст в разметке должен слово в слово совпадать с видимым. Вопросов, которых нет на странице, в разметку не добавляй.`, verify: 'На выбранных страницах есть разметка FAQPage минимум с пятью вопросами, и каждый вопрос и ответ из неё дословно виден на странице.' }
|
|
328
339
|
: { task: `Collect 5 to 7 frequent questions from ${v.peopleGen} with the owner, publish them as a visible block on the main service pages, and only then mark them up with FAQPage. The markup text must match the visible text word for word.`, verify: 'The chosen pages carry FAQPage markup with at least five questions, each question and answer visible on the page word for word.' }),
|
|
340
|
+
// В разметке только мессенджеры: задача не «впиши то, что уже вписано», а найти настоящие профили (cimes.ru, 15.10.2026).
|
|
341
|
+
'trust-profiles-messengers': ({ check, ru }) => {
|
|
342
|
+
// Карточка на площадке на сайте уже есть (виджет рейтинга или ссылка), а в разметке её нет: начинаем с неё (cimes.ru, 15.10.2026).
|
|
343
|
+
const ls = check?.listings;
|
|
344
|
+
if (ls && ls.length) return ru
|
|
345
|
+
? { task: `Впиши в поле sameAs разметки организации адрес карточки компании: ${ls.join(', ')}. Она уже показана на сайте (рейтинг или ссылка в подвале), но в разметке не указана. Потом спроси владельца, есть ли другие настоящие карточки (2ГИС, Google Карты, отраслевые каталоги, соцсети компании), и добавь только существующие. Мессенджеры профилями не считаются.`, verify: `В sameAs разметки организации есть ${ls[0]}, и адрес открывает карточку этой компании.` }
|
|
346
|
+
: { task: `Add the company listing to the sameAs field of the organisation markup: ${ls.join(', ')}. The site already shows it (rating badge or footer link), but the markup does not declare it. Then ask the owner about other real listings and add only those that exist. Messengers are not profiles.`, verify: `The sameAs of the organisation markup contains ${ls[0]}, and it opens this company's listing.` };
|
|
347
|
+
const m = check?.messengersOnly;
|
|
348
|
+
if (!m || !m.length) return null;
|
|
349
|
+
const list = m.join(', ');
|
|
350
|
+
return ru
|
|
351
|
+
? { task: `В разметке организации (sameAs) сейчас указаны только мессенджеры: ${list}. Это способы написать, а не профили компании на площадках. Спроси владельца, где у компании есть настоящие карточки: Яндекс Карты или Яндекс Бизнес, 2ГИС, Google Карты, отраслевые каталоги и рейтинги, соцсети компании. Впиши в sameAs только существующие адреса и поставь ссылки на них в подвал. Если таких карточек нет, задачу не делай и не выдумывай профили.`, verify: 'В sameAs разметки организации есть хотя бы одна ссылка на карточку компании на площадке (карты, каталог, соцсеть), кроме мессенджеров, и она открывает профиль этой компании.' }
|
|
352
|
+
: { task: `The organisation markup (sameAs) lists only messengers now: ${list}. Those are ways to write, not company profiles on platforms. Ask the owner where the business really has listings: maps, directories and ratings, its own social accounts. Put only the ones that exist into sameAs and link them from the footer. If there are none, skip this task and invent nothing.`, verify: 'The sameAs of the organisation markup has at least one link to a company listing on a platform (maps, directory, social network) besides messengers, and it opens this company\'s profile.' };
|
|
353
|
+
},
|
|
329
354
|
sections: ({ audit, ru }) => {
|
|
330
|
-
|
|
355
|
+
// Страница-список (CollectionPage, ItemList) разводит по подразделам, разбивать её на разделы незачем (cimes.ru, 14.10.2026).
|
|
356
|
+
const long = sampleOf(audit).filter((p) => (p.words || 0) >= 600 && (p.h2Count || 0) < 3 && !isListPage(p));
|
|
331
357
|
if (!long.length) return null;
|
|
332
358
|
const list = long.slice(0, 5).map((p) => pathOf(p.url)).join(', ');
|
|
359
|
+
// В отчёте «меньше трёх H2» считается по всем страницам выборки, а в задачу идут только длинные: правило отбора пишем в тексте (cimes.ru, 14.10.2026).
|
|
360
|
+
const few = sampleOf(audit).filter((p) => (p.h2Count || 0) < 3);
|
|
361
|
+
const rest = few.filter((p) => !long.includes(p));
|
|
362
|
+
const isList = isListPage;
|
|
363
|
+
const shortOnes = rest.filter((p) => !isList(p)); const lists = rest.filter(isList);
|
|
364
|
+
const why = rest.length ? (ru
|
|
365
|
+
? ` Из ${few.length} страниц выборки, где меньше трёх H2, сюда вошли страницы с обычным текстом от 600 слов.${shortOnes.length ? ` Короткие (${shortOnes.slice(0, 5).map((p) => pathOf(p.url)).join(', ')}) разбивать не нужно.` : ''}${lists.length ? ` Страницы-списки подразделов (${lists.slice(0, 5).map((p) => pathOf(p.url)).join(', ')}) ведут на другие страницы, разбивать их на разделы не нужно.` : ''}`
|
|
366
|
+
: ` Of the ${few.length} sampled pages with fewer than three H2s only the long ones, 600 words and more, are listed. The others (${rest.slice(0, 5).map((p) => pathOf(p.url)).join(', ')}) are short or are list pages: leave them.`) : '';
|
|
333
367
|
// Подзаголовки уже есть, но размечены H3: задача не «придумать H2», а поднять существующие (14.10.2026).
|
|
334
368
|
if (long.every((p) => (p.h3Count || 0) >= 2)) return ru
|
|
335
|
-
? { task: `На ${long.length === 1 ? 'странице' : 'страницах'} ${list} подзаголовки уже есть, но размечены как H3. Сделай существующие смысловые подзаголовки H2; новые добавляй, только если без них раздел не читается. Текст не
|
|
336
|
-
: { task: `On ${list} the subheadings already exist but are marked up as H3. Make the existing meaningful subheadings H2; add new ones only where a section cannot be read without them. Do not add text
|
|
369
|
+
? { task: `На ${long.length === 1 ? 'странице' : 'страницах'} ${list} подзаголовки уже есть, но размечены как H3. Сделай существующие смысловые подзаголовки H2; новые добавляй, только если без них раздел не читается. Текст не дописывай.${why}`, verify: `${long.length === 1 ? 'На странице' : 'На страницах'} ${list} не меньше трёх заголовков H2 по смыслу текста.` }
|
|
370
|
+
: { task: `On ${list} the subheadings already exist but are marked up as H3. Make the existing meaningful subheadings H2; add new ones only where a section cannot be read without them. Do not add text.${why}`, verify: `${list} carry at least three H2s that follow the text.` };
|
|
337
371
|
return ru
|
|
338
|
-
? { task: `Разбей на разделы ${long.length === 1 ? 'длинную страницу, где' : 'длинные страницы, где'} сейчас меньше трёх заголовков H2: ${list}. Заголовки H2 по смыслу текста. Ничего не дописывай, только
|
|
339
|
-
: { task: `Split into sections the long pages that now have fewer than three H2s: ${list}. H2s that follow the text. Add nothing, only structure
|
|
372
|
+
? { task: `Разбей на разделы ${long.length === 1 ? 'длинную страницу, где' : 'длинные страницы, где'} сейчас меньше трёх заголовков H2: ${list}. Заголовки H2 по смыслу текста. Ничего не дописывай, только структурируй.${why}`, verify: `${long.length === 1 ? 'На странице' : 'На страницах'} ${list} не меньше трёх заголовков H2 по смыслу текста.` }
|
|
373
|
+
: { task: `Split into sections the long pages that now have fewer than three H2s: ${list}. H2s that follow the text. Add nothing, only structure.${why}`, verify: `The pages ${list} carry at least three H2s that follow the text.` };
|
|
340
374
|
},
|
|
341
375
|
'conv-cta': ({ ru, v }) => (ru
|
|
342
376
|
? { task: `Поставь на первый экран каждой главной страницы одно понятное действие, которое на сайте уже есть: ${v.cta}. Одно действие на экран. Не выдумывай новых обещаний и сроков.`, verify: 'На каждой такой странице в верхних 15 процентах высоты, в основном блоке страницы, а не в шапке, есть кнопка или ссылка с этим действием.' }
|
|
@@ -358,7 +392,7 @@ const PF = {
|
|
|
358
392
|
? { task: 'Выключи архивы авторов в плагине SEO (в Yoast: SEO, Отображение в поисковой выдаче, вкладка Архивы; в английском интерфейсе Search Appearance, Archives), тогда страница автора уйдёт из карты сайта. Если имя автора выглядит как логин (например, admin), смени отображаемое имя в профиле пользователя WordPress на название компании.', verify: 'В карте сайта нет страниц авторов, и в разметке страниц нет имени-логина.' }
|
|
359
393
|
: { task: 'Switch off author archives in the SEO plugin (Yoast: Search Appearance, Archives), so the author page leaves the sitemap. If the author name looks like a login (admin, say), change the display name in the WordPress user profile to the company name.', verify: 'The sitemap has no author pages, and the page markup carries no login-like name.' }) : null),
|
|
360
394
|
ttfb: ({ ru }) => (ru
|
|
361
|
-
? { task: 'Сначала сделай три замера из страны, где живут
|
|
395
|
+
? { task: 'Сначала сделай три замера из страны, где живут посетители сайта. Если ответ больше 0,8 секунды (порог, который Google считает хорошим), включи кеширование готовых страниц: плагин кеша, кеш хостинга или CDN. Менять хостинг только из-за нашего замера не нужно: мы мерили с сервера в Германии, а не из страны посетителей сайта.', verify: 'Три замера подряд из страны посетителей (curl -o /dev/null -w "%{time_starttransfer}" адрес главной) показывают не больше 0,8 секунды.' }
|
|
362
396
|
: { task: 'First take three measurements from the country your visitors live in. If the answer takes over 0.8 seconds (the level Google calls good), turn on page caching: a cache plugin, host cache or a CDN. Do not move host on our measurement alone: we measure from elsewhere.', verify: 'Three runs in a row from your visitors\' country (curl -o /dev/null -w "%{time_starttransfer}" home URL) show 0.8 seconds or less.' }),
|
|
363
397
|
thin: ({ audit, ru, v }) => {
|
|
364
398
|
const sm = sampleOf(audit); const home = sm[0];
|
|
@@ -387,15 +421,17 @@ const PF = {
|
|
|
387
421
|
: null) || (ru
|
|
388
422
|
? { task: 'Политику конфиденциальности и согласие на обработку персональных данных готовит владелец или юрист: текст не сочиняй. Когда текст будет, опубликуй его отдельной страницей, поставь ссылку в подвал и добавь к каждой форме, которая собирает имя, телефон или почту, галочку согласия со ссылкой на политику. Если правила или условия услуг лежат внутри другой страницы, вынеси их на отдельную и тоже поставь ссылку в подвал.', verify: 'В подвале каждой страницы есть ссылка на политику, она открывается с кодом 200, и у каждой формы есть галочка согласия.' }
|
|
389
423
|
: { task: 'The owner or a lawyer writes the privacy policy and terms: do not draft legal text. Once it exists, publish it as its own page, link it from the footer, and add a consent checkbox linking to it on every form that collects a name, phone or email.', verify: 'Every page footer links the policy, it answers 200, and every form has a consent checkbox.' }),
|
|
390
|
-
'trust-profiles': (
|
|
424
|
+
'trust-profiles': (ctx) => PF['trust-profiles-messengers'](ctx) || (/ссылки на сайте есть|pages link to/i.test(String(ctx.check.value)) ? (ctx.ru
|
|
391
425
|
? { task: 'Профили, на которые сайт уже ссылается, впиши в поле sameAs разметки организации. Спроси владельца, есть ли другие настоящие профили (карты, отраслевые каталоги, соцсети), и добавь только подтверждённые.', verify: 'В разметке организации есть sameAs со ссылками на профили, которые уже стоят на сайте, и каждая открывает профиль этой компании.' }
|
|
392
426
|
: { task: 'Put the profiles the site already links to into the sameAs field of the organisation markup. Ask the owner for any other real profiles (maps, directories, social) and add only confirmed ones.', verify: 'The organisation markup carries sameAs with the profiles already linked on the site, each opening this company\'s profile.' }) : null),
|
|
393
427
|
sources: ({ ru }) => (ru
|
|
394
428
|
? { task: 'Подпиши источник к каждой цифре статистики на сайте (доли, количества людей, сравнения «в N раз»): ссылкой на первоисточник или словами. Цены, адреса, телефоны и сроки услуг источника не требуют. Если источника нет ни у тебя, ни у владельца, цифру убери. Не ставь ссылку наугад.', verify: 'Рядом с каждой цифрой статистики есть ссылка на первоисточник или названный словами источник, либо цифра убрана.' }
|
|
395
429
|
: { task: 'Add a source to every statistic on the site (shares, counts of people, "N times" comparisons): a link to the original or a named source. Prices, addresses, phones and service timings need none. If neither you nor the owner has a source, remove the figure. Never guess a link.', verify: 'Every statistic has a link or a named source beside it, or has been removed.' }),
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
430
|
+
// Какие страницы переписывать, называем адресами: «главные страницы» исполнителю ничего не говорит (cimes.ru, 14.10.2026).
|
|
431
|
+
'answer-first': ({ audit, ru, v }) => { const noAnswer = sampleOf(audit).filter((p) => !p.answerFirst); const miss = noAnswer.filter((p) => !isListPage(p)).slice(0, 12).map((p) => pathOf(p.url)); const lists = noAnswer.filter(isListPage).map((p) => pathOf(p.url)); const where = (miss.length ? (ru ? ` Страницы выборки без такого начала: ${miss.join(', ')}.` : ` Sampled pages without such an opening: ${miss.join(', ')}.`) : '') + (lists.length ? (ru ? ` Остальные (${lists.slice(0, 5).join(', ')}) это страницы-списки подразделов, их начало переписывать не нужно.` : ` The others (${lists.slice(0, 5).join(', ')}) are list pages leading to sub-pages: leave their opening as it is.`) : ''); const r = (ru
|
|
432
|
+
? { task: `Перепиши начало главных страниц так, чтобы первый абзац сразу отвечал на вопрос ${v.peopleGen}: что это, кому и на каких условиях. От двадцати до девяноста слов, с конкретной цифрой из фактов владельца; источник цифры назови рядом (у собственных цен и сроков источник сам владелец, отдельно его называть не нужно). Если цифры нет, не придумывай: спроси владельца.`, verify: 'Первый абзац после заголовка на этих страницах укладывается в 20-90 слов и содержит цифру; если цифра не своя (не цена и не срок владельца), рядом назван источник.' }
|
|
433
|
+
: { task: `Rewrite the opening of the main pages so the first paragraph answers what ${v.people} ask: what it is, for whom, on what terms. Twenty to ninety words with a concrete figure from the owner's facts, its source named beside it (the owner's own prices and timings need no source). If there is no figure, do not invent one: ask the owner.`, verify: 'The first paragraph after the heading on those pages is 20 to 90 words and carries a figure; if the figure is not the owner\'s own price or timing, its source is named beside it.' }); return { ...r, task: `${r.task}${where}` }; },
|
|
434
|
+
|
|
399
435
|
};
|
|
400
436
|
|
|
401
437
|
const HEAD = {
|
|
@@ -512,6 +548,10 @@ export function agentPrompt(check, opts = {}) {
|
|
|
512
548
|
}
|
|
513
549
|
|
|
514
550
|
/** Все задания по открытым находкам аудита, в порядке важности. */
|
|
551
|
+
const datesGeneric = ({ ru }) => (ru
|
|
552
|
+
? { task: 'Сверь даты публикации в разметке (datePublished) с настоящими датами материалов: если тема сортирует записи по этой дате, сначала переключи сортировку на настоящую дату, потом исправь даты в разметке. Затем выведи дату публикации видимой строкой на странице каждого материала.', verify: 'На странице каждого материала видна дата публикации, и она совпадает с datePublished в его разметке.' }
|
|
553
|
+
: { task: 'Check the published dates in the markup (datePublished) against the real dates of the posts: if the theme sorts posts by that date, switch the sorting first, then correct the markup dates. Then show the published date as a visible line on every post.', verify: 'Every post shows its published date on the page, and it matches datePublished in its markup.' });
|
|
554
|
+
|
|
515
555
|
export function agentPrompts(audit, opts = {}) {
|
|
516
556
|
const lang = opts.lang === 'ru' ? 'ru' : 'en';
|
|
517
557
|
const limit = Number(opts.limit ?? 0);
|
|
@@ -523,7 +563,9 @@ export function agentPrompts(audit, opts = {}) {
|
|
|
523
563
|
const nicheNow = vocabFor(audit, lang).niche;
|
|
524
564
|
const AGENT_ONLY = new Set(['agent-card', 'api-catalog', 'agent-markdown']);
|
|
525
565
|
const checks = source.filter((c) => (c.status === 'bad' || c.status === 'warn') && !(AGENT_ONLY.has(c.id) && nicheNow !== 'b2b'));
|
|
526
|
-
|
|
566
|
+
// Сначала приоритеты отчёта в их порядке, потом остальное: план говорит «остальные задачи по порядку из файла задач» (cimes.ru, 14.10.2026).
|
|
567
|
+
const pri = (audit?.priorityIds || []).map((id) => checks.find((c) => c.id === id)).filter(Boolean);
|
|
568
|
+
const ordered = [...pri, ...checks.filter((c) => c.status === 'bad' && !pri.includes(c)), ...checks.filter((c) => c.status === 'warn' && !pri.includes(c))];
|
|
527
569
|
const chosen = limit > 0 ? ordered.slice(0, limit) : ordered;
|
|
528
570
|
const platform = opts.platform || detectPlatform(audit);
|
|
529
571
|
return chosen.map((c) => ({ id: c.id, hasOwnText: Boolean(P[promptKey(c.id)] || PF[promptKey(c.id)]), text: agentPrompt(c, { lang, platform, audit }) }));
|
package/src/render.mjs
CHANGED
|
@@ -24,9 +24,9 @@ const LABELS = {
|
|
|
24
24
|
eyebrowAudit: 'Digital marketing audit', coverTitle: 'SEO, AEO and GEO<br>audit report',
|
|
25
25
|
auditSubject: 'Audit subject', reportDate: 'Report date', auditType: 'Audit type', preparedBy: 'Prepared by',
|
|
26
26
|
reproducible: 'Every status in this report was read from the public site and can be reproduced from it as it stood on the report date.',
|
|
27
|
-
collectedBy: (tool, date) => `Public signals were collected by ${tool} on ${date}. Every status in this report can be reproduced from the site as it stood on that day.`,
|
|
27
|
+
collectedBy: (tool, date) => `Public signals were collected by ${String(tool).replace(/^@operstack\/audit\s*/, 'the OperStack checker, version ')} on ${date}. Every status in this report can be reproduced from the site as it stood on that day.`,
|
|
28
28
|
secSummary: 'Executive summary', summaryEyebrow: '01 · Summary', keyTakeaway: 'Key takeaway:', threePriorities: 'Three priorities',
|
|
29
|
-
scorecardFoot: 'These six scores are not a breakdown of the number above and will not add up to it: they count the checks in this report, which go wider than AI visibility. Each score counts the checks in this report: a check that passes scores one, a check that needs attention a half, a failing check nothing. An area marked <strong>not measured</strong> is an area this audit does not test, and is never scored on an impression. Every figure here can be recomputed from
|
|
29
|
+
scorecardFoot: 'These six scores are not a breakdown of the number above and will not add up to it: they count the checks in this report, which go wider than AI visibility. Each score counts the checks in this report: a check that passes scores one, a check that needs attention a half, a failing check nothing. An area marked <strong>not measured</strong> is an area this audit does not test, and is never scored on an impression. Every figure here can be recomputed from the check tables below.',
|
|
30
30
|
notMeasured: 'not measured',
|
|
31
31
|
overallLabel: 'AI visibility score',
|
|
32
32
|
grades: { A: 'Strong', B: 'Workable', C: 'Weak', D: 'Poor', E: 'Critical' },
|
|
@@ -38,7 +38,7 @@ const LABELS = {
|
|
|
38
38
|
const read = o.basis ? `It was read from ${o.basis.pages} page${o.basis.pages === 1 ? '' : 's'} of the site${o.basis.sitemapUnchecked ? ', and the sitemap did not answer in time, which is not counted against the score either way' : o.basis.sitemapRead ? ' and its sitemap' : ''} for this score; the rest of the report counts the whole sample. ` : '';
|
|
39
39
|
const where = o.source === 'visibility:reused'
|
|
40
40
|
? `This is the same figure you already saw on the free check${o.measuredAt ? ` on ${String(o.measuredAt).slice(0, 10)}` : ''}. It is not measured again here, so the page, the email and this report always carry one number. `
|
|
41
|
-
: 'Measured here by the same code and the same settings as the free check at oper
|
|
41
|
+
: 'Measured here by the same code and the same settings as the free check at oper‑stack.com/ai‑visibility. ';
|
|
42
42
|
const areas = Array.isArray(o.areas) ? o.areas : [];
|
|
43
43
|
const measured = areas.filter((a) => a.measured !== false && a.score !== null);
|
|
44
44
|
const sumUp = areas.length && measured.length < areas.length
|
|
@@ -60,10 +60,10 @@ const LABELS = {
|
|
|
60
60
|
secTechnical: 'Technical SEO', technicalEyebrow: '04 · Technical', technicalTitle: 'Technical and on-page checklist', colCheck: 'Check', colStatus: 'Status', colFinding: 'Finding',
|
|
61
61
|
secContent: 'Content and on-page', contentEyebrow: '05 · Content', contentTitle: 'Content and on-page SEO', strengths: 'Strengths', weaknesses: 'Weaknesses', gaps: 'Structural gaps: pages that do not exist yet',
|
|
62
62
|
secAeo: 'AEO and GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Answer engines: AEO', aeoLead: 'Whether the site\'s answers can be lifted into "People also ask", AI overviews, ChatGPT and Perplexity.', aeoWorks: 'What already works', aeoBlocks: 'What blocks the wins', recommendation: 'Recommendation:', geoTitle: 'Generative engines: GEO', colSignal: 'Signal', colDetail: 'Detail',
|
|
63
|
-
secOffpage: 'Off-page
|
|
63
|
+
secOffpage: 'Off-page and conversion', offpageEyebrow: '07 · Off-page', offpageTitle: 'Off-page and trust', conversionEyebrow: '08 · Conversion', conversionTitle: 'Conversion and UX', colElement: 'Element', limitationsEyebrow: '10 · Limitations', sourcesTitle: 'Where the data comes from', colSource: 'Source', colAccess: 'Access', colWhatRead: 'What was read',
|
|
64
64
|
sourcesFoot: 'Every score in this report is computed from sources marked free. Nothing that costs money is counted in a score, so you can re-run this audit yourself and get the same or very close numbers.',
|
|
65
65
|
limitations: 'Limitations',
|
|
66
|
-
secRoadmap: 'Roadmap', roadmapEyebrow: '09 · Roadmap', demandEyebrow: '11 · Demand', demandTitle: 'Demand map', secDemand: 'Demand', roadmapTitle: 'What to do, in order', keyMessage: 'Key message:',
|
|
66
|
+
secRoadmap: 'Roadmap and limitations', roadmapEyebrow: '09 · Roadmap', demandEyebrow: '11 · Demand', demandTitle: 'Demand map', secDemand: 'Demand', roadmapTitle: 'What to do, in order', keyMessage: 'Key message:',
|
|
67
67
|
runningHead: 'SEO, AEO and GEO audit',
|
|
68
68
|
statusOk: '✓ OK', statusWarn: '△ Partial', statusBad: '✗ Problem', statusNa: '· Note',
|
|
69
69
|
areas: null,
|
|
@@ -74,9 +74,9 @@ const LABELS = {
|
|
|
74
74
|
eyebrowAudit: 'Аудит цифрового маркетинга', coverTitle: 'Аудит сайта:<br>SEO, AEO и GEO',
|
|
75
75
|
auditSubject: 'Что проверяли', reportDate: 'Дата отчёта', auditType: 'Тип аудита', preparedBy: 'Кто готовил',
|
|
76
76
|
reproducible: 'Каждый статус в этом отчёте прочитан с публичного сайта, и его можно перепроверить по сайту на дату отчёта.',
|
|
77
|
-
collectedBy: (tool, date) => `Публичные сигналы собраны инструментом ${tool
|
|
77
|
+
collectedBy: (tool, date) => `Публичные сигналы собраны ${date ? `${date} ` : ''}инструментом ${String(tool).replace(/^@operstack\/audit\s*/, 'проверки OperStack, версия ')}. Любой статус в этом отчёте можно перепроверить по сайту в том виде, в каком он был в этот день.`,
|
|
78
78
|
secSummary: 'Главное', summaryEyebrow: '01 · Итог', keyTakeaway: 'Главный вывод:', threePriorities: 'Три приоритета',
|
|
79
|
-
scorecardFoot: 'Эти шесть оценок не разбивка балла выше и в сумме его не дают: они считают проверки самого отчёта, а он шире, чем видимость в ИИ. Каждая оценка считает проверки из этого же отчёта: пройденная проверка
|
|
79
|
+
scorecardFoot: 'Эти шесть оценок не разбивка балла выше и в сумме его не дают: они считают проверки самого отчёта, а он шире, чем видимость в ИИ. Каждая оценка считает проверки из этого же отчёта: пройденная проверка даёт балл, частичная половину балла, проваленная ноль. Область с пометкой <strong>не измерялось</strong> это область, которую аудит не проверяет, и она никогда не оценивается на глаз. Любую цифру отсюда можно пересчитать по таблицам проверок ниже.',
|
|
80
80
|
notMeasured: 'не измерялось',
|
|
81
81
|
overallLabel: 'Балл видимости в ИИ',
|
|
82
82
|
grades: { A: 'Сильно', B: 'Рабочее состояние', C: 'Слабо', D: 'Плохо', E: 'Критично' },
|
|
@@ -86,13 +86,13 @@ const LABELS = {
|
|
|
86
86
|
const read = o.basis ? `Прочитано ${p} ${pl} сайта${o.basis.sitemapUnchecked ? ', а карта сайта не ответила вовремя, и это не зачтено ни в плюс, ни в минус' : o.basis.sitemapRead ? ' и его карта' : ''}: это для балла; остальные разделы отчёта считаются по всей выборке. ` : '';
|
|
87
87
|
const where = o.source === 'visibility:reused'
|
|
88
88
|
? `Это ровно то число, которое вы уже видели в бесплатной проверке${o.measuredAt ? ` ${String(o.measuredAt).slice(0, 10)}` : ''}. Здесь оно не меряется заново, поэтому страница, письмо и этот отчёт всегда несут одно число. `
|
|
89
|
-
: 'Измерено здесь тем же кодом и с теми же настройками, что у бесплатной проверки на oper
|
|
89
|
+
: 'Измерено здесь тем же кодом и с теми же настройками, что у бесплатной проверки на oper‑stack.ru/ai‑visibility. ';
|
|
90
90
|
const areas = Array.isArray(o.areas) ? o.areas : [];
|
|
91
91
|
const measured = areas.filter((a) => a.measured !== false && a.score !== null);
|
|
92
92
|
const sumUp = areas.length && measured.length < areas.length
|
|
93
93
|
? `Из пяти областей ниже измерены ${measured.length}, они дают ${measured.reduce((t, a) => t + a.score, 0)} из ${measured.reduce((t, a) => t + a.max, 0)}, и балл это та же доля, перенесённая на сто. Область с пометкой «не измерялось» не зачтена ни в плюс, ни в минус. `
|
|
94
94
|
: 'Пять областей ниже дают в сумме ровно этот балл, его можно сложить руками. ';
|
|
95
|
-
return `${where}${read}${sumUp}Повторная проверка может
|
|
95
|
+
return `${where}${read}${sumUp}Повторная проверка может отличаться на пункт-другой: проверка, которая успела ответить в один прогон, может не успеть в следующий, а балл никогда не засчитывает сайту в минус то, что не удалось прочитать.`;
|
|
96
96
|
},
|
|
97
97
|
secondMeasure: 'Второе, отдельное измерение: как этот отчёт оценивает собственные проверки',
|
|
98
98
|
ofHundred: 'из 100', ofHundredShort: 'ИЗ 100', aiVisibility: 'ИИ-видимость', seoBasics: 'Основы SEO',
|
|
@@ -106,12 +106,12 @@ const LABELS = {
|
|
|
106
106
|
secOverview: 'О сайте', overviewEyebrow: '02 · Обзор', whatSiteIs: 'Что это за сайт', parameter: 'Параметр', value: 'Значение', pagesSampled: 'Проверенные страницы', colUrl: 'Адрес', colTitle: 'Заголовок', colWords: 'Слов',
|
|
107
107
|
secCritical: 'Критичное', criticalEyebrow: '03 · Срочно', criticalTitle: 'Что чинить первым', whatItCosts: 'Чем это грозит', theFix: 'Как чинится', noBody: 'У этой проблемы нет описания в файле аудита.', noCritical: 'Критичных дефектов в публичных сигналах не найдено.',
|
|
108
108
|
secTechnical: 'Техническое SEO', technicalEyebrow: '04 · Техника', technicalTitle: 'Техническая проверка и страницы', colCheck: 'Проверка', colStatus: 'Статус', colFinding: 'Что нашли',
|
|
109
|
-
secContent: 'Контент и страницы', contentEyebrow: '05 · Контент', contentTitle: 'Контент и внутренняя оптимизация', strengths: 'Сильные стороны', weaknesses: 'Слабые места', gaps: 'Структурные дыры:
|
|
110
|
-
secAeo: 'AEO и GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Ответные системы: AEO', aeoLead: '
|
|
111
|
-
secOffpage: '
|
|
109
|
+
secContent: 'Контент и страницы', contentEyebrow: '05 · Контент', contentTitle: 'Контент и внутренняя оптимизация', strengths: 'Сильные стороны', weaknesses: 'Слабые места', gaps: 'Структурные дыры: страницы, которых пока нет',
|
|
110
|
+
secAeo: 'AEO и GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Ответные системы: AEO', aeoLead: 'Здесь видно, может ли ответ с вашего сайта попасть в блок «Люди также спрашивают» и в ответы ИИ: ChatGPT, Perplexity, Алисы.', aeoWorks: 'Что уже работает', aeoBlocks: 'Что мешает', recommendation: 'Рекомендация:', geoTitle: 'Генеративные системы: GEO', colSignal: 'Сигнал', colDetail: 'Подробности',
|
|
111
|
+
secOffpage: 'Ссылки и конверсия', offpageEyebrow: '07 · Ссылки', offpageTitle: 'Ссылки и доверие', conversionEyebrow: '08 · Конверсия', conversionTitle: 'Конверсия и удобство', colElement: 'Элемент', limitationsEyebrow: '10 · Ограничения', sourcesTitle: 'Откуда взяты данные', colSource: 'Источник', colAccess: 'Доступ', colWhatRead: 'Что прочитали',
|
|
112
112
|
sourcesFoot: 'Каждая оценка в этом отчёте посчитана из источников с пометкой «бесплатно». Ничего платного ни в одну оценку не входит, поэтому вы можете повторить этот аудит сами и получить те же или очень близкие цифры.',
|
|
113
113
|
limitations: 'Ограничения',
|
|
114
|
-
secRoadmap: 'План работ', roadmapEyebrow: '09 · План', demandEyebrow: '11 · Спрос', demandTitle: 'Карта спроса', secDemand: 'Спрос', roadmapTitle: 'Что делать и в каком порядке', keyMessage: 'Главное сообщение:',
|
|
114
|
+
secRoadmap: 'План работ и ограничения', roadmapEyebrow: '09 · План', demandEyebrow: '11 · Спрос', demandTitle: 'Карта спроса', secDemand: 'Спрос', roadmapTitle: 'Что делать и в каком порядке', keyMessage: 'Главное сообщение:',
|
|
115
115
|
runningHead: 'Аудит SEO, AEO и GEO',
|
|
116
116
|
statusOk: '✓ Норма', statusWarn: '△ Частично', statusBad: '✗ Проблема', statusNa: '· Заметка',
|
|
117
117
|
areas: AREAS_RU,
|
|
@@ -314,7 +314,7 @@ function css() {
|
|
|
314
314
|
table.pages.appendix td:nth-child(1), table.pages.appendix th:nth-child(1) { width: 33%; }
|
|
315
315
|
table.pages.appendix td:nth-child(2), table.pages.appendix th:nth-child(2) { width: 35%; }
|
|
316
316
|
table.pages.appendix td:nth-child(n+3), table.pages.appendix th:nth-child(n+3) { width: 8%; text-align: right; font-variant-numeric: tabular-nums; }
|
|
317
|
-
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .status-na { color: var(--muted); }
|
|
317
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .status-na { color: var(--muted); white-space: nowrap; }
|
|
318
318
|
.cards { display: grid; gap: 3mm; margin: 4mm 0; }
|
|
319
319
|
.card { border-left: 3px solid var(--danger); background: #fbf1f0; padding: 3.5mm 4mm; border-radius: 0 4px 4px 0; }
|
|
320
320
|
.card.warn { border-left-color: var(--warn); background: #fdf8ee; }
|
|
@@ -335,7 +335,7 @@ function css() {
|
|
|
335
335
|
|
|
336
336
|
const header = (audit, section, L) => `<div class="page-header"><strong>${esc(audit.client.name)}</strong><span>${esc(L.runningHead)} · ${esc(section)}</span></div>`;
|
|
337
337
|
const footer = (audit, n, by) => `<div class="page-footer"><span>${esc(by || audit.client.preparedBy || 'OperStack')} · ${esc(audit.client.reportDate)}</span><span>${n}</span></div>`;
|
|
338
|
-
const list = (items) => `<ul>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ul>`;
|
|
338
|
+
const list = (items) => `<ul>${(items || []).filter((i) => String(i ?? '').trim()).map((i) => `<li>${t(i)}</li>`).join('')}</ul>`;
|
|
339
339
|
const olist = (items) => `<ol>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ol>`;
|
|
340
340
|
const statusCell = (s, L) => `<td class="${STATUS_CLASS[s] || STATUS_CLASS.na}">${L[STATUS_KEYS[s] || 'statusNa']}</td>`;
|
|
341
341
|
|
|
@@ -455,7 +455,10 @@ export function toHtml(audit, brand = null) {
|
|
|
455
455
|
* второй про проверки самого отчёта и что делать. Оба полные.
|
|
456
456
|
*/
|
|
457
457
|
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>`);
|
|
458
|
-
|
|
458
|
+
// Фраза про «не измерялось» только если такая область в отчёте есть: иначе владелец ищет пометку, которой нет (cimes.ru, 14.10.2026).
|
|
459
|
+
const anyUnmeasured = Object.values(a.scores || {}).some((v) => typeof v !== 'number');
|
|
460
|
+
const scoreFoot = anyUnmeasured ? L.scorecardFoot : L.scorecardFoot.replace(/(Область с пометкой|An area marked) <strong>[^<]+<\/strong>[^.]*\. ?/, '');
|
|
461
|
+
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">${scoreFoot}</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>`);
|
|
459
462
|
/*
|
|
460
463
|
* Обзор: таблица прочитанных страниц не влезала на лист и переливалась. Режем её по счёту
|
|
461
464
|
* строк, как техническую. Первый лист несёт параметры сайта и первые строки, дальше только
|
|
@@ -496,7 +499,8 @@ export function toHtml(audit, brand = null) {
|
|
|
496
499
|
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>`);
|
|
497
500
|
}
|
|
498
501
|
const cardBody = (c) => [
|
|
499
|
-
|
|
502
|
+
// Текст карточки это отдельное предложение под заголовком: с заглавной (cimes.ru, 14.10.2026).
|
|
503
|
+
c.text ? `<p>${t(`${String(c.text)[0].toUpperCase()}${String(c.text).slice(1)}`)}</p>` : '',
|
|
500
504
|
c.cost ? `<p><span class="card-tag">${L.whatItCosts}</span>${t(c.cost)}</p>` : '',
|
|
501
505
|
c.fix ? `<p><span class="card-tag">${L.theFix}</span>${t(c.fix)}</p>` : '',
|
|
502
506
|
].join('') || '<p class="card-empty">${L.noBody}</p>';
|
|
@@ -506,8 +510,11 @@ export function toHtml(audit, brand = null) {
|
|
|
506
510
|
* он занимал целую страницу одной строкой «критичных дефектов не найдено», а это в отчёте за
|
|
507
511
|
* деньги выглядит как пустой лист. Сам факт не пропадает: он сказан в сводке.
|
|
508
512
|
*/
|
|
513
|
+
let criticalInline = '';
|
|
509
514
|
if ((a.critical || []).length) {
|
|
510
|
-
|
|
515
|
+
// Один-два пункта на целом листе выглядят пустым листом (cimes.ru, 14.10.2026): тогда раздел встаёт в начало технической проверки.
|
|
516
|
+
if (a.critical.length <= 3) criticalInline = `<div class="eyebrow">${L.criticalEyebrow}</div><h2>${L.criticalTitle}</h2><div class="cards">${cards}</div>`;
|
|
517
|
+
else 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>`);
|
|
511
518
|
}
|
|
512
519
|
/*
|
|
513
520
|
* Длинная таблица режется на листы по счёту строк, а не переливается сама.
|
|
@@ -524,15 +531,18 @@ export function toHtml(audit, brand = null) {
|
|
|
524
531
|
const techPages = [];
|
|
525
532
|
let bucket = [];
|
|
526
533
|
let units = 0;
|
|
534
|
+
if (criticalInline) units = 3 * a.critical.length;
|
|
527
535
|
for (const c of techChecks) {
|
|
528
536
|
const cost = c.comment ? 1.5 : 1;
|
|
529
537
|
if (units + cost > TECH_UNITS_PER_PAGE && bucket.length) { techPages.push(bucket); bucket = []; units = 0; }
|
|
530
538
|
bucket.push(c); units += cost;
|
|
531
539
|
}
|
|
532
540
|
if (bucket.length) techPages.push(bucket);
|
|
541
|
+
// Хвост в одну-две строки не печатаем отдельным листом: он уходит на предыдущий, подгонка листа его ужмёт (cimes.ru, 14.10.2026).
|
|
542
|
+
if (techPages.length > 1 && techPages[techPages.length - 1].length <= 2) { const tail = techPages.pop(); techPages[techPages.length - 1].push(...tail); }
|
|
533
543
|
techPages.forEach((chunk, i) => {
|
|
534
544
|
const title = techPages.length > 1 ? `${L.technicalTitle} (${i + 1}/${techPages.length})` : L.technicalTitle;
|
|
535
|
-
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>`);
|
|
545
|
+
pages.push(`<div class="page">${header(a, i === 0 && criticalInline ? `${L.secCritical} · ${L.secTechnical}` : L.secTechnical, L)}${i === 0 ? criticalInline : ''}<div class="eyebrow"${i === 0 && criticalInline ? ' style="margin-top:7mm"' : ''}>${L.technicalEyebrow}</div><h2>${esc(title)}</h2><table>${techHead}${chunk.map(rowHtml).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
|
|
536
546
|
});
|
|
537
547
|
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>`);
|
|
538
548
|
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('');
|
|
@@ -542,7 +552,7 @@ export function toHtml(audit, brand = null) {
|
|
|
542
552
|
* AEO и GEO стояли на одном листе: два заголовка, два списка, две врезки и таблица сигналов.
|
|
543
553
|
* Лист переливал почти на восемьсот пикселей. Теперь по разделу на лист.
|
|
544
554
|
*/
|
|
545
|
-
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>`);
|
|
555
|
+
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>${checkTable('aeo')}${footer(a, n++, preparedBy)}</div>`);
|
|
546
556
|
/*
|
|
547
557
|
* Таблица сигналов GEO выросла до двадцати пяти строк и на лист не влезает. Режем её, как и
|
|
548
558
|
* остальные длинные таблицы: по счёту строк, с продолжением под своим номером.
|
|
@@ -558,7 +568,9 @@ export function toHtml(audit, brand = null) {
|
|
|
558
568
|
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>`);
|
|
559
569
|
});
|
|
560
570
|
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>`);
|
|
561
|
-
|
|
571
|
+
// Строки, которые слово в слово повторяют таблицу проверок выше, не печатаем второй раз (cimes.ru, 14.10.2026).
|
|
572
|
+
const convRows = (a.conversion.rows || []).filter(([, v]) => !checksBy('conversion').some((c) => String(c.value || '').trim() === String(v || '').trim()));
|
|
573
|
+
pages.push(`<div class="page">${header(a, L.secOffpage, L)}<div class="eyebrow">${L.conversionEyebrow}</div><h2>${L.conversionTitle}</h2>${checkTable('conversion')}${convRows.length ? `<table><tr><th>${L.colElement}</th><th>${L.colFinding}</th></tr>${convRows.map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table>` : ''}${footer(a, n++, preparedBy)}</div>`);
|
|
562
574
|
|
|
563
575
|
/*
|
|
564
576
|
* Вы и ваши конкуренты. Балл видимости стоит первым, потому что он у всех посчитан по десяти
|
|
@@ -679,6 +691,10 @@ export function toHtml(audit, brand = null) {
|
|
|
679
691
|
if (a.demand && Array.isArray(a.demand.queries)) {
|
|
680
692
|
pages.push(`<div class="page">${header(a, L.secDemand, L)}<div class="eyebrow">${L.demandEyebrow}</div><h2>${L.demandTitle}</h2>${renderDemandHtml(a.demand, { lang: a.meta?.lang === 'ru' ? 'ru' : 'en' })}${footer(a, n++, preparedBy)}</div>`);
|
|
681
693
|
}
|
|
694
|
+
// Номера разделов сквозные: без раздела «Что чинить первым» после 02 шёл 04 (cimes.ru, 14.10.2026).
|
|
695
|
+
{ const order = []; for (const m of pages.join('').matchAll(/class="eyebrow">(\d{2}) ·/g)) if (!order.includes(m[1])) order.push(m[1]);
|
|
696
|
+
const map = Object.fromEntries(order.map((n, i) => [n, String(i + 1).padStart(2, '0')]));
|
|
697
|
+
for (let i = 0; i < pages.length; i++) pages[i] = pages[i].replace(/class="eyebrow">(\d{2}) ·/g, (m, n) => `class="eyebrow">${map[n] || n} ·`); }
|
|
682
698
|
return `<!doctype html><html lang="${a.meta?.lang === 'ru' ? 'ru' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: ${esc(L.docTitle)}</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}${accentCss}</style></head><body>${pages.join('\n')}${FIT_SCRIPT}</body></html>`;
|
|
683
699
|
}
|
|
684
700
|
|
|
@@ -691,12 +707,12 @@ export function toHtml(audit, brand = null) {
|
|
|
691
707
|
* живёт в HTML, а не в печатающем коде. Проверка числа листов после печати: pdfPageCount.
|
|
692
708
|
*/
|
|
693
709
|
export const FIT_SCRIPT = `<script>(document.fonts?document.fonts.ready:Promise.resolve()).then(function(){var H=297*96/25.4;function rowsOf(t){return Array.prototype.filter.call(t.querySelectorAll('tr'),function(r){return !r.querySelector('th')})}
|
|
694
|
-
for(var g=0;g<30;g++){var did=false,pages=document.querySelectorAll('.page');for(var i=0;i<pages.length;i++){var el=pages[i];if(
|
|
695
|
-
var clone=el.cloneNode(true),ctbs=clone.querySelectorAll('table'),ctb=ctbs[ctbs.length-1],crows=rowsOf(ctb),k=rows.length;while(el.scrollHeight>H+1&&k>1){rows[--k].style.display='none'}if(k===rows.length)continue;
|
|
710
|
+
for(var g=0;g<30;g++){var did=false,pages=document.querySelectorAll('.page');for(var i=0;i<pages.length;i++){var el=pages[i],h0=el.scrollHeight;if(h0<=H+1)continue;var tbs=el.querySelectorAll('table');if(!tbs.length)continue;var tb=tbs[tbs.length-1],rows=rowsOf(tb);if(el.scrollHeight/H<1.06||rows.length<4)continue;
|
|
711
|
+
var clone=el.cloneNode(true),ctbs=clone.querySelectorAll('table'),ctb=ctbs[ctbs.length-1],crows=rowsOf(ctb),k=rows.length;while(el.scrollHeight>H+1&&k>1){rows[--k].style.display='none'}if(k===rows.length)continue;if(rows.length-k<=3&&h0*0.85<=H){for(var r3=k;r3<rows.length;r3++)rows[r3].style.display='';continue}
|
|
696
712
|
for(var j=k;j<rows.length;j++)rows[j].parentNode.removeChild(rows[j]);for(var j2=0;j2<k;j2++)crows[j2].parentNode.removeChild(crows[j2]);
|
|
697
|
-
var keep=[];for(var n=ctb;n&&n!==clone;n=n.parentNode)keep.push(n);Array.prototype.slice.call(clone.children).forEach(function(c){if(keep.indexOf(c)>=0)return;if(/page-footer|masthead|eyebrow/.test(c.className||''))return;if(/^H[1-3]$/.test(c.tagName))return;if(c.compareDocumentPosition(ctb)&4)c.parentNode.removeChild(c)});
|
|
713
|
+
var keep=[];for(var n=ctb;n&&n!==clone;n=n.parentNode)keep.push(n);Array.prototype.slice.call(clone.children).forEach(function(c){if(keep.indexOf(c)>=0)return;if(/page-footer|page-header|masthead|eyebrow/.test(c.className||''))return;if(/^H[1-3]$/.test(c.tagName))return;if(c.compareDocumentPosition(ctb)&4)c.parentNode.removeChild(c)});
|
|
698
714
|
var top=tb;while(top.parentNode&&top.parentNode!==el)top=top.parentNode;var nx=top.nextElementSibling;while(nx){var after=nx.nextElementSibling;if(!/page-footer/.test(nx.className||''))nx.parentNode.removeChild(nx);nx=after}el.parentNode.insertBefore(clone,el.nextSibling);did=true;break}if(!did)break}
|
|
699
|
-
Array.prototype.forEach.call(document.querySelectorAll('.page'),function(el){var h=el.scrollHeight;if(h>H+1){var z=Math.max(0.
|
|
715
|
+
Array.prototype.forEach.call(document.querySelectorAll('.page'),function(el){var h=el.scrollHeight;if(h>H+1){var z=Math.max(0.85,Math.floor((H-2)/h*1000)/1000);el.style.zoom=z;el.style.height=(H/z)+'px';el.style.minHeight=(H/z)+'px';el.style.overflow='hidden';if(z===0.85&&h*z>H+1)document.documentElement.setAttribute('data-overflow','1')}});
|
|
700
716
|
var no=0;Array.prototype.forEach.call(document.querySelectorAll('.page-footer span:last-child'),function(s){if(/^\\d+$/.test(s.textContent.trim()))s.textContent=String(++no)});
|
|
701
717
|
document.documentElement.setAttribute('data-pages',String(document.querySelectorAll('.page').length))});</script>`;
|
|
702
718
|
|
package/src/rendered.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
14
14
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
15
|
import { tmpdir } from 'node:os';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
|
+
import { parse as parseHtml } from 'node-html-parser';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Это страница сайта или собственная страница ошибки браузера.
|
|
@@ -131,8 +132,23 @@ export function renderedDom(url, { chrome = null, timeoutMs = 45000, waitMs = 80
|
|
|
131
132
|
});
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
const CHROME_STRIP = /<(nav|footer|aside|script|style|noscript|template)[\s\S]*?<\/\1>/gi;
|
|
135
|
-
|
|
135
|
+
const CHROME_STRIP = /<(nav|header|footer|aside|script|style|noscript|template)[\s\S]*?<\/\1>/gi;
|
|
136
|
+
// Баннер cookie и согласия рисует скрипт, но это не текст страницы: у cimes.ru 14.10.2026 он один дал «скрыто 19 процентов».
|
|
137
|
+
// Меню и выпадающие списки, которые скрипт достраивает вне <nav>, тоже не текст страницы: у cimes.ru 14.10.2026 «скрыто 20 процентов»
|
|
138
|
+
// были пункты меню «Услуги» и «Готовые решения». Убираем одинаково из обеих версий страницы.
|
|
139
|
+
const COOKIE_BOX = /cookie|consent|gdpr|cmp-|onetrust|cookiebot|(^|[\s_-])(menu|submenu|mega-?menu|dropdown|navbar|burger|mobile-nav|offcanvas)([\s_-]|$)/i;
|
|
140
|
+
const dropCookieBoxes = (html) => {
|
|
141
|
+
try {
|
|
142
|
+
const root = parseHtml(String(html));
|
|
143
|
+
// Только небольшой блок: класс «cookie-accepted» на <body> или обёртке страницы не повод выбросить всю страницу.
|
|
144
|
+
for (const el of root.querySelectorAll('[class], [id]')) {
|
|
145
|
+
if (/^(html|body|main|article)$/i.test(el.tagName || '') || !el.parentNode) continue;
|
|
146
|
+
if (COOKIE_BOX.test(`${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`) && countWords(el.text || '') <= 400) el.remove();
|
|
147
|
+
}
|
|
148
|
+
return root.toString();
|
|
149
|
+
} catch { return String(html); }
|
|
150
|
+
};
|
|
151
|
+
const plain = (html) => dropCookieBoxes(html)
|
|
136
152
|
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
137
153
|
.replace(CHROME_STRIP, ' ')
|
|
138
154
|
.replace(/<[^>]+>/g, ' ')
|
|
@@ -176,7 +192,10 @@ export function jsBlindnessCheck(pages, lang = 'en') {
|
|
|
176
192
|
const worst = measured.slice().sort((a, b) => b.hiddenShare - a.hiddenShare)[0];
|
|
177
193
|
const median = measured.map((p) => p.hiddenShare).sort((a, b) => a - b)[Math.floor(measured.length / 2)];
|
|
178
194
|
const status = measured.some((p) => p.verdict === 'bad') ? 'bad' : measured.some((p) => p.verdict === 'warn') ? 'warn' : 'ok';
|
|
179
|
-
|
|
195
|
+
// Ничего не скрыто: так и пишем, без «видно всё, кроме 0 процентов» (cimes.ru, 14.10.2026).
|
|
196
|
+
const value = worst.hiddenShare === 0
|
|
197
|
+
? (lang === 'ru' ? `без скриптов виден весь текст: проверено на ${measured.length} ${measured.length === 1 ? 'странице' : 'страницах'}` : `all the text is visible without JavaScript, checked on ${measured.length} page${measured.length === 1 ? '' : 's'}`)
|
|
198
|
+
: lang === 'ru'
|
|
180
199
|
? `без скриптов видно всё, кроме ${median} процентов текста (медиана по ${measured.length} ${measured.length === 1 ? 'странице' : 'страницам'}), хуже всего ${worst.url}: там скрыто ${worst.hiddenShare} процентов, ${WORD.ru(worst.raw)} без скриптов против ${WORD.ru(worst.rendered)} в браузере`
|
|
181
200
|
: `${median}% of the text is invisible without JavaScript (median over ${measured.length} page${measured.length === 1 ? '' : 's'}); worst is ${worst.url} at ${worst.hiddenShare}%, ${worst.raw} words without scripts against ${worst.rendered} in a browser`;
|
|
182
201
|
// Слова по размеру беды: на четырнадцати процентах писать «страница почти пустая» это враньё
|