@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
package/src/i18n.mjs
CHANGED
|
@@ -21,9 +21,22 @@ const plural = (n, one, few, many) => {
|
|
|
21
21
|
};
|
|
22
22
|
/** Проценты по-русски склоняются: 31 процент, 32 процента, 35 процентов. */
|
|
23
23
|
const pct = (n) => plural(Number(n), 'процент', 'процента', 'процентов');
|
|
24
|
+
// «1: t.me» владелец не читает: называем площадку по-человечески (cimes.ru, 14.10.2026).
|
|
25
|
+
const PROFILE_NAMES = { 't.me': 'Telegram', 'telegram.me': 'Telegram', 'vk.com': 'ВКонтакте', 'ok.ru': 'Одноклассники', 'dzen.ru': 'Дзен', 'rutube.ru': 'Rutube', 'youtube.com': 'YouTube', 'instagram.com': 'Instagram', 'facebook.com': 'Facebook', 'linkedin.com': 'LinkedIn', 'x.com': 'X', 'twitter.com': 'X', 'tiktok.com': 'TikTok', 'pinterest.com': 'Pinterest', 'github.com': 'GitHub', 'behance.net': 'Behance', 'prodoctorov.ru': 'ПроДокторов', '2gis.ru': '2ГИС', 'zoon.ru': 'Zoon', 'yandex.ru': 'Яндекс', 'avito.ru': 'Авито', 'profi.ru': 'Профи' };
|
|
26
|
+
const profileName = (h) => PROFILE_NAMES[String(h).trim().replace(/^www\./, '')] || String(h).trim();
|
|
27
|
+
/** Строка профилей для отчёта: «указан 1 профиль: Telegram», «2 profiles: Telegram, VK». Голые «1: t.me» владелец не читает. */
|
|
28
|
+
export function profileLine(hosts, lang = 'en', total = hosts.length) {
|
|
29
|
+
const names = [...new Set(hosts.map(profileName))].join(', ');
|
|
30
|
+
if (lang === 'ru') return `${total === 1 ? 'указан' : 'указано'} ${plural(total, 'профиль', 'профиля', 'профилей')}: ${names}`;
|
|
31
|
+
return `${total} profile${total === 1 ? '' : 's'}: ${names}`;
|
|
32
|
+
}
|
|
24
33
|
const pages = (n) => plural(Number(n), 'страница', 'страницы', 'страниц');
|
|
25
34
|
const pagesOf = (a, b) => `${a} из ${pages(b)}`;
|
|
26
35
|
/** «из 21 страницы», «из 20 страниц»: после «из» родительный падеж. */
|
|
36
|
+
// «1 из 20 страниц указывает»: глагол по числу страниц, где это есть (cimes.ru, 14.10.2026).
|
|
37
|
+
const vb = (n, one, many) => (Number(n) % 10 === 1 && Number(n) % 100 !== 11 ? one : many);
|
|
38
|
+
// «есть на 7 страницах», «на 1 странице»: предложный падеж после «на» (cimes.ru, 14.10.2026).
|
|
39
|
+
const pagesLoc = (n) => (Number(n) % 10 === 1 && Number(n) % 100 !== 11 ? 'странице' : 'страницах');
|
|
27
40
|
const pagesGen = (n) => (Number(n) % 10 === 1 && Number(n) % 100 !== 11 ? 'страницы' : 'страниц');
|
|
28
41
|
|
|
29
42
|
/** Перевод «сколько проверок пройдено» из подписи под оценкой. */
|
|
@@ -35,7 +48,7 @@ export function localiseBasisNote(note) {
|
|
|
35
48
|
}
|
|
36
49
|
const [, ok, total, warn, bad] = m;
|
|
37
50
|
const parts = [`${ok} из ${plural(Number(total), 'проверки', 'проверок', 'проверок')} пройдено`];
|
|
38
|
-
if (warn) parts.push(
|
|
51
|
+
if (warn) parts.push(`${Number(warn)} частично`);
|
|
39
52
|
if (bad) parts.push(plural(Number(bad), 'провалена', 'провалены', 'провалено'));
|
|
40
53
|
return parts.join(', ');
|
|
41
54
|
}
|
|
@@ -50,6 +63,7 @@ const RU = {
|
|
|
50
63
|
label: 'Склейка адреса с www',
|
|
51
64
|
value: (v) => (/redirects to/.test(v) ? 'адрес с www перенаправляется на основной'
|
|
52
65
|
: /does not resolve/.test(v) ? 'адрес с www не существует'
|
|
66
|
+
: /did not answer/.test(v) ? 'адрес с www не ответил вовремя, склейку проверить не удалось'
|
|
53
67
|
: `адрес с www отвечает ${nums(v)[0] || ''} и не перенаправляет`),
|
|
54
68
|
},
|
|
55
69
|
ttfb: { label: 'Время до первого байта', value: (v) => `${nums(v)[0]} мс`, comment: () => 'один запрос от проверяющего, а не лабораторный тест' },
|
|
@@ -98,15 +112,15 @@ const RU = {
|
|
|
98
112
|
canonical: { label: 'Канонический адрес', value: (v) => (/missing|none/i.test(v) ? 'не указан' : /of \d+ sampled/.test(v) ? `указан на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки` : 'указан'), comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
99
113
|
// Добавлено 17.09.2026 вместе с новыми проверками. Русский отчёт платный, английские строки в нём
|
|
100
114
|
// это такой же брак, как русский отчёт англоязычному покупателю.
|
|
101
|
-
'title-all': { label: 'Заголовок вкладки на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]}
|
|
102
|
-
'description-all': { label: 'Описание на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]}
|
|
103
|
-
'schema-all': { label: 'Разметка на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]}
|
|
104
|
-
'og-title': { label: 'og:title на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]}
|
|
105
|
-
'og-image': { label: 'og:image на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]}
|
|
115
|
+
'title-all': { label: 'Заголовок вкладки на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
116
|
+
'description-all': { label: 'Описание на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
117
|
+
'schema-all': { label: 'Разметка на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
118
|
+
'og-title': { label: 'og:title на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (c ? 'без него при пересылке ссылки видно голый адрес или чужой заголовок' : '') },
|
|
119
|
+
'og-image': { label: 'og:image на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (c ? 'без og:image мессенджеры и соцсети показывают ссылку без картинки' : '') },
|
|
106
120
|
'twitter-card': { label: 'Карточка Twitter', value: (v) => (/no twitter/.test(v) ? 'не задана' : v.replace('twitter:card', 'тип')), comment: (c) => (c ? 'на неё опираются X и часть читалок, когда Open Graph неполный' : '') },
|
|
107
|
-
'breadcrumbs-schema': { label: 'Разметка хлебных крошек', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${
|
|
108
|
-
'howto-schema': { label: 'Разметка пошаговых инструкций', value: (v) => (/no step-by-step/.test(v) ? 'пошаговых страниц в выборке нет' : `есть на ${
|
|
109
|
-
'product-schema': { label: 'Разметка товара', value: (v) => (/no product/.test(v) ? 'страниц товара в выборке нет' : `есть на ${
|
|
121
|
+
'breadcrumbs-schema': { label: 'Разметка хлебных крошек', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`), comment: (c) => (c ? 'крошки говорят системе, где страница стоит, и показываются в ответе рядом со ссылкой' : '') },
|
|
122
|
+
'howto-schema': { label: 'Разметка пошаговых инструкций', value: (v) => (/no step-by-step/.test(v) ? 'пошаговых страниц в выборке нет' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`), comment: (c) => (c ? 'ставить только там, где страница правда ведёт по шагам: разметить ею что-то другое значит соврать системе' : '') },
|
|
123
|
+
'product-schema': { label: 'Разметка товара', value: (v) => (/no product/.test(v) ? 'страниц товара в выборке нет' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`), comment: (c) => (c ? 'только для страниц, которые правда продают названную вещь с ценой' : '') },
|
|
110
124
|
'redirect-chain': { label: 'Переадресации до главной', value: (v) => (/without a redirect/.test(v) ? 'адрес отвечает сразу, без переадресаций' : `${plural(Number(nums(v)[0]), 'переадресация', 'переадресации', 'переадресаций')} до ответа`), comment: (c) => (c ? 'направьте первый адрес сразу на конечный: каждый прыжок стоит времени и веса ссылки' : '') },
|
|
111
125
|
favicon: { label: 'Значок сайта', value: (v) => (/declared/.test(v) ? 'объявлен в шапке страницы' : /answers 200/.test(v) ? '/favicon.ico отвечает' : 'не объявлен, и /favicon.ico не отвечает'), comment: (c) => (c ? 'вкладка, закладка и карточка при пересылке ссылки останутся с пустым квадратом' : '') },
|
|
112
126
|
'engine-chatgpt': { label: 'ChatGPT может читать сайт', value: (v) => (/blocked/.test(v) ? `закрыт в robots.txt: ${v.replace(/^blocked in robots.txt:\s*/, '')}` : 'открыт'), comment: (c) => (c ? 'ChatGPT не заберёт страницу, когда она понадобится для ответа, и не процитирует вас даже там, где вы лучший ответ' : '') },
|
|
@@ -118,26 +132,26 @@ const RU = {
|
|
|
118
132
|
viewport: { label: 'Мобильный viewport', value: (v) => (/missing/.test(v) ? 'не задан' : v), comment: (c) => (c ? 'запрещает масштабирование: плохо для доступности и мобильного поиска' : '') },
|
|
119
133
|
og: { label: 'Карточки для соцсетей', value: (v) => (/none/.test(v) ? 'нет' : v), comment: (c) => (/shortcode/.test(c) ? 'в описании служебный код вместо текста' : '') },
|
|
120
134
|
schema: { label: 'Разметка на главной', value: (v) => (/no JSON-LD/.test(v) ? 'разметки нет' : /invalid/.test(v) ? 'разметка есть, но с ошибкой' : v) },
|
|
121
|
-
'faq-schema': { label: 'Разметка вопросов FAQPage', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${
|
|
135
|
+
'faq-schema': { label: 'Разметка вопросов FAQPage', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`) },
|
|
122
136
|
'org-schema': { label: 'Разметка организации', value: (v) => (/not found/.test(v) ? 'не найдена: ответным системам не к чему привязать сайт' : 'есть') },
|
|
123
|
-
resources: { label: '
|
|
137
|
+
resources: { label: 'Файлы главной страницы', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'файл скриптов', 'файла скриптов', 'файлов скриптов')}, ${plural(Number(n[1]), 'стиль', 'стиля', 'стилей')}, ${plural(Number(n[2]), 'изображение', 'изображения', 'изображений')}`; } },
|
|
124
138
|
hreflang: { label: 'hreflang', value: (v) => (/none/.test(v) ? 'нет, сайт одноязычный' : `${plural(Number(nums(v)[0]), 'альтернатива', 'альтернативы', 'альтернатив')}`) },
|
|
125
|
-
cms: { label: '
|
|
139
|
+
cms: { label: 'Система управления сайтом (CMS)', value: (v) => v.replace(' (detected from the page code)', ' (определён по коду страницы)').replace(' (from the X-Powered-By server header)', ' (по заголовку сервера X-Powered-By)') },
|
|
126
140
|
xmlrpc: { label: 'xmlrpc.php', value: (v) => `отвечает ${nums(v)[0]}`, comment: () => 'открытая точка для атак, пользы для поиска никакой' },
|
|
127
141
|
alt: { label: 'Подписи к картинкам', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} картинок имеют подпись, это ${pct(n[2])}, на ${n[3]} ${Number(n[3]) % 10 === 1 && Number(n[3]) % 100 !== 11 ? 'странице' : 'страницах'} выборки`; } },
|
|
128
142
|
'h1-sample': { label: 'Структура заголовков', value: (v) => (/one H1 (?:per page|on every sampled page)/.test(v) ? 'по одному H1 на каждой странице выборки' : v.split('; ').map((part) => { const n = nums(part); return /no H1/.test(part) ? `H1 нет на ${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки` : `больше одного H1 на ${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки`; }).join('; ')) },
|
|
129
143
|
'dup-titles': { label: 'Повторы заголовков', value: (v) => (/its own title/.test(v) ? 'у каждой проверенной страницы свой заголовок' : `${plural(Number(nums(v)[0]), 'заголовок повторяется', 'заголовка повторяются', 'заголовков повторяются')} на разных страницах`) },
|
|
130
|
-
thin: { label: '
|
|
144
|
+
thin: { label: 'Объём текста', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки короче 300 слов`) },
|
|
131
145
|
utility: { label: 'Служебные страницы в карте сайта', value: (v) => (/^1 URL: /.test(v) ? `1 адрес: ${v.slice(7)}` : `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')}, например ${v.split('such as ')[1] || ''}`), comment: (c) => { const map = { 'author pages': 'страницы авторов', 'tag pages': 'страницы тегов', 'cart and checkout pages': 'корзина и оформление заказа', 'wishlist pages': 'избранное', 'account pages': 'личный кабинет', feeds: 'ленты RSS', 'utility pages': 'служебные страницы' }; const k = String(c).replace(/ add noise to the index$/, '').split(', ').map((x) => map[x] || x); return `${k.join(', ')}: в индексе не нужны и засоряют его`; } },
|
|
132
146
|
'answer-first': {
|
|
133
147
|
label: 'Ответ в первом абзаце',
|
|
134
|
-
value: (v) => { const n = nums(v);
|
|
148
|
+
value: (v) => { const n = nums(v); const miss = Number(n[1]) - Number(n[0]); return miss > 0 ? `прямого ответа с цифрой в первом абзаце (20-90 слов сразу после H1) нет на ${miss} из ${n[1]} ${pagesGen(n[1])} выборки` : `прямой ответ с цифрой в первом абзаце есть на всех ${n[1]} страницах выборки`; },
|
|
135
149
|
comment: (c) => (c ? 'именно этот абзац ответная система забирает в цитату' : ''),
|
|
136
150
|
},
|
|
137
|
-
sections: { label: 'Разбивка на разделы', value: (v) => { const n = nums(v);
|
|
151
|
+
sections: { label: 'Разбивка на разделы', value: (v) => { const n = nums(v); const few = Number(n[1]) - Number(n[0]); return few > 0 ? `меньше трёх подзаголовков H2 на ${few} из ${n[1]} ${pagesGen(n[1])} выборки` : `три и более подзаголовка H2 на всех ${n[1]} страницах выборки`; }, comment: (c) => (c ? 'системы цитируют разделы, а не сплошной текст' : '') },
|
|
138
152
|
tables: { label: 'Таблицы в тексте', value: (v) => (/no table/.test(v) ? 'на проверенных страницах таблиц нет' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки используют таблицу`), comment: (c) => (c ? 'таблица это второй по частоте формат цитирования после первого абзаца' : '') },
|
|
139
|
-
sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); if (n.length < 2) return /no sampled page carries a figure/.test(v) ? 'на страницах выборки нет цифр, которым нужен источник' : 'не измеряли'; return `${n[0]} из ${pages(n[1])} выборки, где есть
|
|
140
|
-
dates: { label: 'Открытые даты публикации', value: (v) => (/no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : /in the markup/.test(v) ? `${nums(v)[0]} из ${
|
|
153
|
+
sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); if (n.length < 2) return /no sampled page carries a figure/.test(v) ? 'на страницах выборки нет цифр, которым нужен источник' : 'не измеряли'; return `${n[0]} из ${pages(n[1])} выборки, где есть цифры статистики (доли, количества, сравнения), называют, откуда взята цифра, словами или ссылкой рядом с ней`; }, comment: (c) => (c ? 'цифра без источника это первое, что система выбрасывает' : '') },
|
|
154
|
+
dates: { label: 'Открытые даты публикации', value: (v) => (/not sampled from the sitemap/.test(v) ? (() => { const n = (k) => Number((v.match(new RegExp(`${k} (\\d+)`)) || [])[1] || 0); const parts = [n('blog') && `статьи блога (${n('blog')})`, n('news') && `новости (${n('news')})`, n('cases') && `работы из портфолио (${n('cases')})`].filter(Boolean); const list = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} и ${parts[parts.length - 1]}` : parts[0]; return `${list} в выборку не вошли, на страницах выборки датировать нечего`; })() : /nothing to date/.test(v) ? 'статей, новостей и кейсов в выборке нет: датировать нечего' : /precede published/.test(v) ? `дата изменения стоит раньше даты публикации на ${nums(v)[0]} ${plural(Number(nums(v)[0]), 'статье', 'статьях', 'статьях').replace(/^\d+ /, '')}` : /no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : /in the markup/.test(v) ? `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки ${vb(nums(v)[0], 'указывает', 'указывают')} дату в разметке${nums(v)[2] === undefined ? '' : Number(nums(v)[0]) === 1 ? (Number(nums(v)[2]) ? ', и на этой странице та же дата видна в тексте' : ', но в тексте этой страницы даты не видно') : `; на ${nums(v)[2]} из них та же дата видна и в тексте`}` : `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки ${vb(nums(v)[0], 'показывает', 'показывают')} дату`) },
|
|
141
155
|
'llms-health': { label: 'Страницы из llms.txt', value: (v) => (/^all /.test(v) ? `все ${nums(v)[0]} проверенных страниц отвечают и открыты для индексации` : v.replace(/(\d+) dead/, '$1 мёртвых').replace(/(\d+) redirecting/, '$1 с переадресацией').replace(/(\d+) closed to indexing/, '$1 закрытых от индексации').replace(/ of (\d+) checked: /, ' из $1 проверенных: ')), comment: (c) => (c ? 'ИИ читает этот файл вместо обхода сайта: каждая мёртвая или закрытая запись это страница, которую он процитирует неверно или не процитирует вовсе' : '') },
|
|
142
156
|
'agent-card': { label: 'Карточка агента (/.well-known/agent.json)', value: (v) => (/valid JSON/.test(v) ? 'есть, и это правильный JSON' : /not JSON/.test(v) ? 'есть, но это не JSON' : 'нет'), comment: (c) => (c ? 'ранний и необязательный стандарт: файл описывает программного ИИ-агента, если он у компании есть; без своего агента он не нужен' : '') },
|
|
143
157
|
'api-catalog': { label: 'Каталог API (RFC 9727)', value: (v) => (/^present/.test(v) ? 'есть' : 'нет'), comment: (c) => (c ? 'необязательно, и нужно только если у бизнеса действительно есть API, который агент мог бы вызвать' : '') },
|
|
@@ -147,15 +161,15 @@ const RU = {
|
|
|
147
161
|
'trust-contact': {
|
|
148
162
|
label: 'Прямые контакты',
|
|
149
163
|
value: (v) => (/no phone, email or messenger/.test(v) ? 'ни телефона, ни почты, ни мессенджера не опубликовано'
|
|
150
|
-
: v.replace(/\bphone\b/g, 'телефон').replace(/\bemail\b/g, 'почта').replace(/a messenger/g, 'мессенджер').replace(
|
|
164
|
+
: v.replace(/\bphone\b/g, 'телефон').replace(/\bemail\b/g, 'почта').replace(/a messenger/g, 'мессенджер').replace(/, | and /g, ', ').replace(/, ([^,]+)$/, ' и $1')),
|
|
151
165
|
},
|
|
152
|
-
'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' :
|
|
166
|
+
'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/listing shown on the site is missing/.test(v) ? (() => { const urls = v.split('markup: ')[1] || ''; const where = /yandex/.test(urls) ? 'в Яндекс Картах' : /2gis/.test(urls) ? 'в 2ГИС' : 'на картах'; const m = (v.match(/only messengers in sameAs \(([^)]*)\)/) || [])[1]; return `${m ? `в разметке (sameAs) указаны только мессенджеры (${m}); ` : ''}ваша карточка ${where} на сайте показана, но в разметке не указана: ${urls}`; })() : /only messengers in sameAs/.test(v) ? `в разметке указаны только мессенджеры (${(v.match(/\(([^)]*)\)/) || ['', ''])[1]}), профилей на площадках нет` : /no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' : /^\d+ profiles?:/.test(v) ? (() => { const n = Number(nums(v)[0]); return `${n === 1 ? 'указан' : 'указано'} ${plural(n, 'профиль', 'профиля', 'профилей')}: ${v.split(': ').slice(1).join(': ')}`; })() : /^\d+:/.test(v) ? (() => { const n = Number(nums(v)[0]); const names = v.split(': ').slice(1).join(': ').split(/,\s*/).map(profileName).join(', '); return `${n === 1 ? 'указан' : 'указано'} ${plural(n, 'профиль', 'профиля', 'профилей')}: ${names}`; })() : v), comment: (c) => (c ? 'ссылки на настоящие профили компании в разметке помогают поиску и ИИ убедиться, что компания существует, и это ничего не стоит' : '') },
|
|
153
167
|
'trust-about': { label: 'Страница о компании', value: (v) => (/no about/.test(v) ? 'страницы о компании нет или на неё не ведут ссылки' : 'на неё ведут ссылки с проверенных страниц') },
|
|
154
168
|
'trust-policies': { label: 'Политика и условия', value: (v) => (/neither/.test(v) ? 'ни политики конфиденциальности, ни условий на сайте нет' : `есть: ${v.replace(/privacy/g, 'политика').replace(/terms/g, 'условия').replace(/legal/g, 'правовая страница').replace(/cookie/g, 'куки').replace(/disclaimer/g, 'оговорка').replace(/consent/g, 'согласие на обработку данных')}`) },
|
|
155
|
-
'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${
|
|
156
|
-
'conv-cta': { label: 'Призыв к действию
|
|
157
|
-
'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки)` : /no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${pct(nums(v)[0])} вниз по
|
|
158
|
-
'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до
|
|
169
|
+
'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'даёт', 'дают')} форму, контактную ссылку или страницу контактов`; } },
|
|
170
|
+
'conv-cta': { label: 'Призыв к действию на первом экране', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'ставит', 'ставят')} его в первых ${n[2]} процентах страницы`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
|
|
171
|
+
'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/pop-up/.test(v) ? `форма открывается во всплывающем окне по кнопке на ${nums(v)[0]} из ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'страницы' : 'страниц'} выборки; кнопка на первом экране на ${nums(v)[2]}` : /messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки)` : /no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : (Number(nums(v)[1]) === 1 ? `форма заявки есть на одной странице выборки, на ${pct(nums(v)[0])} вниз по коду страницы` : `медиана ${pct(nums(v)[0])} вниз по коду страницы, измерено на ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'странице' : 'страницах'}`)), comment: (c) => (/button that leads/.test(c) ? 'на первом экране уже есть кнопка, которая ведёт к форме' : c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
|
|
172
|
+
'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => (/only (enquiry )?form/.test(v) ? `в единственной форме заявки в выборке ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')}` : `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`), comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
|
|
159
173
|
'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
|
|
160
174
|
};
|
|
161
175
|
|
|
@@ -178,8 +192,10 @@ export function localiseChecks(checks, lang = 'ru') {
|
|
|
178
192
|
// Строки, которые уже написаны по-русски (второй взгляд по сайту ставит флаг localised), второй раз не переводим.
|
|
179
193
|
// Уже русская строка (без английских слов вне кавычек) второй раз не переводится: перевод ждёт английский шаблон.
|
|
180
194
|
const ruAlready = (x) => { const t = String(x || '').replace(/"[^"]*"|«[^»]*»/g, ''); return Boolean(c.localised) || (/[А-Яа-яЁё]/.test(t) && !/[A-Za-z]{3,}/.test(t)); };
|
|
181
|
-
|
|
182
|
-
|
|
195
|
+
// Правка проверки (applyFix) уже написана на языке отчёта: её не переводим, иначе «www» в русском тексте
|
|
196
|
+
// отправлял строку в английский шаблон и правка пропадала (cimes.ru, 14.10.2026, три круга без схождения).
|
|
197
|
+
try { if (r.value && !c.fixedValue && !ruAlready(c.value)) out.value = r.value(c.value ?? ''); } catch { /* оставляем исходный текст */ }
|
|
198
|
+
try { if (r.comment && !c.fixedComment && !ruAlready(c.comment)) out.comment = r.comment(c.comment ?? ''); } catch { /* оставляем исходный текст */ }
|
|
183
199
|
return out;
|
|
184
200
|
});
|
|
185
201
|
}
|
package/src/index.mjs
CHANGED
|
@@ -15,3 +15,4 @@ export { consistencyCheck } from './consistency.mjs';
|
|
|
15
15
|
export { detectNiche, vocabFor } from './niche.mjs';
|
|
16
16
|
export { renderTasksHtml, renderTasksPdf } from './tasks-pdf.mjs';
|
|
17
17
|
export { printFitted, pdfPageCount } from './render.mjs';
|
|
18
|
+
export { presentationCheck, STATUS_OK_SHARE, STATUS_OK_MIN } from './presentation.mjs';
|
package/src/narrative.mjs
CHANGED
|
@@ -31,23 +31,23 @@ const T = {
|
|
|
31
31
|
*/
|
|
32
32
|
sells: (d, p) => d ? `${d} (прочитано из описания главной страницы)` : p ? `по тексту главной: «${p}»; описания для поиска у главной нет` : 'описания для поиска у главной нет, а из текста главной это не понять',
|
|
33
33
|
leadWhat: (h, n) => `Мы прочитали ${n} ${plural(n, 'страницу', 'страницы', 'страниц')} сайта ${h} так же, как их открывают поисковик и ИИ.`,
|
|
34
|
-
leadGood: (areas) => areas.length ? `Хорошо сделано вот что: ${listRu(areas)}.` : 'Ни одна из шести областей пока не выглядит сильной стороной.',
|
|
35
|
-
leadBad: (area, n) => n ? `Тянет сайт вниз другое: ${n} ${plural(n, 'проверка провалена', 'проверки провалены', 'проверок провалено')}
|
|
34
|
+
leadGood: (areas) => areas.length === 6 ? 'Все шесть областей проверки в хорошем состоянии.' : areas.length ? `Хорошо сделано вот что: ${listRu(areas)}.` : 'Ни одна из шести областей пока не выглядит сильной стороной.',
|
|
35
|
+
leadBad: (area, n, partial = 0) => n ? `Тянет сайт вниз другое: ${n} ${plural(n, 'проверка провалена', 'проверки провалены', 'проверок провалено')}${area ? `, тяжелее всего дела в области «${area}»` : ''}.` : area ? `Проваленных проверок нет, но до потолка не хватает мелочей, тяжелее всего в области «${area}».` : `Проваленных проверок нет${partial ? `, не полностью пройдены ${partial} ${plural(partial, 'проверка', 'проверки', 'проверок')}, они ниже` : ''}.`,
|
|
36
36
|
verdict: (openN, mech, people = 'покупателей') => openN
|
|
37
|
-
? `${openN} ${plural(openN, 'проверка пройдена', 'проверки пройдены', 'проверок пройдены')} не полностью.
|
|
37
|
+
? `${openN} ${plural(openN, 'проверка пройдена', 'проверки пройдены', 'проверок пройдены')} не полностью. ${mech === 0 ? `Все они требуют ваших фактов: цифр, источников, ответов на вопросы ${people}.` : mech === openN ? 'Все они правятся технически, без переписывания текстов, то есть быстро.' : `Из них ${mech} ${plural(mech, 'правится', 'правятся', 'правятся')} технически, без переписывания текстов, то есть быстро, а ${openN - mech} ${plural(openN - mech, 'потребует', 'потребуют', 'потребуют')} ваших фактов: цифр, источников, ответов на вопросы ${people}.`}`
|
|
38
38
|
: `Главное: критичных поломок нет. Дальше сайт растёт не починкой, а текстами: ответами на вопросы ${people}, цифрами и источниками, по которым вас можно процитировать.`,
|
|
39
39
|
noteOffer: (cta, contact) => `Что видит человек, попавший на сайт: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
|
|
40
40
|
ctaYes: 'на первом экране есть понятный призыв к действию',
|
|
41
41
|
ctaNo: 'на первом экране нечего нажать, и это значит, что пришедший уходит',
|
|
42
42
|
ctaSome: 'призыв к действию на первом экране есть не на всех страницах',
|
|
43
|
-
contactYes: 'связаться можно
|
|
43
|
+
contactYes: 'связаться можно с любой проверенной страницы',
|
|
44
44
|
contactNo: 'связаться с большинства страниц нельзя, человеку приходится искать',
|
|
45
45
|
strengthNone: 'Сильных сторон в текстах проверки не нашли.',
|
|
46
|
-
weakNone: '
|
|
46
|
+
weakNone: 'В текстах существующих страниц слабых мест не нашли.',
|
|
47
47
|
gapsNone: 'Пробелов по страницам из выборки не видно.',
|
|
48
48
|
aeoWorksNone: 'Пока ничего из того, что помогает попасть в ответы, на сайте не работает.',
|
|
49
49
|
aeoBlocksNone: 'Прямых помех для попадания в ответы не нашли.',
|
|
50
|
-
aeoRec: (p) => p ? `Начните с одной страницы: ${p}. Поставьте в её начало прямой ответ с
|
|
50
|
+
aeoRec: (p) => p ? `Начните с одной страницы: ${p}. Поставьте в её начало прямой ответ с цифрой; если цифра не ваша цена или срок, назовите рядом её источник. Так страницу можно процитировать.` : 'Поставьте в начало каждой важной страницы прямой ответ с цифрой и назовите рядом источник. Цитируют то, что можно вырезать одним абзацем.',
|
|
51
51
|
geoEntity: ['Кто вы, понятно машине', (ok) => ok ? 'да' : 'нет'],
|
|
52
52
|
notMeasured: 'не измеряли',
|
|
53
53
|
geoMentions: 'Упоминания на чужих сайтах',
|
|
@@ -56,14 +56,14 @@ const T = {
|
|
|
56
56
|
geoCallout: (ok) => ok
|
|
57
57
|
? 'Роботы, которые достают страницу, чтобы процитировать её в ответе, на сайт пущены. Это половина дела: дальше вопрос в том, есть ли что цитировать.'
|
|
58
58
|
: 'Роботы, которые достают страницу для цитаты в ответе ИИ, на сайт не пущены. Пока это так, попасть в ответы невозможно физически, чего бы вы ни написали.',
|
|
59
|
-
offpageListed: (profiles) => profiles.length ?
|
|
59
|
+
offpageListed: (profiles) => profiles.length ? '' : 'Ни одного профиля компании на других площадках в коде сайта не заявлено.',
|
|
60
60
|
offpageNote: 'Мы не покупаем данные о ссылках, поэтому не пишем ни цифр про их количество, ни оценок за них. Что проверяемо бесплатно: сказал ли сайт сам, где ещё вас искать, и совпадает ли это с реальностью.',
|
|
61
61
|
convForm: (v) => v,
|
|
62
62
|
priceRow: 'Цена видна',
|
|
63
63
|
priceNo: 'на проверенных страницах цены нет',
|
|
64
64
|
priceYes: (n, total) => `да: цена есть на ${n} из ${total} проверенных страниц`,
|
|
65
65
|
closing: (host, bad) => bad
|
|
66
|
-
? `Мы посмотрели ${host} глазами машины и написали, что увидели, без общих слов. Сначала делается
|
|
66
|
+
? `Мы посмотрели ${host} глазами машины и написали, что увидели, без общих слов. Сначала делается первая неделя плана: три главных приоритета этого отчёта, потом остальные задачи по порядку. Для части задач нужны ваши факты: цифры, сроки, сведения о компании. Их знаете только вы, и исполнитель спросит их у вас.`
|
|
67
67
|
: `Мы посмотрели ${host} глазами машины. Технически сайт в порядке, чинить нечего, и это честный вывод, а не комплимент. Рост дальше только через тексты: ответы на вопросы покупателей с цифрами и источниками.`,
|
|
68
68
|
weekFill: 'Больше ничего срочного не нашли',
|
|
69
69
|
monthFill: 'Тексты по вашим фактам: ответы в начале страниц, источники к цифрам, недостающие страницы',
|
|
@@ -75,8 +75,8 @@ const T = {
|
|
|
75
75
|
unknownLang: 'not declared in the page code',
|
|
76
76
|
sells: (d, p) => d ? `${d} (read from the homepage description)` : p ? `from the home page text: "${p}"; the home page has no search description` : 'the home page has no search description, and its text does not say it either',
|
|
77
77
|
leadWhat: (h, n) => `We read ${n} page${n === 1 ? '' : 's'} of ${h} the way machines read them: the way a search engine and an AI open them.`,
|
|
78
|
-
leadGood: (areas) => areas.length ? `What is done well: ${listEn(areas)}.` : 'None of the six areas reads as a strength yet.',
|
|
79
|
-
leadBad: (area, n) => n ? `What holds it back: ${n} check${n === 1 ? '' : 's'} failed
|
|
78
|
+
leadGood: (areas) => areas.length === 6 ? 'All six areas of the check are in good shape.' : areas.length ? `What is done well: ${listEn(areas)}.` : 'None of the six areas reads as a strength yet.',
|
|
79
|
+
leadBad: (area, n, partial = 0) => n ? `What holds it back: ${n} check${n === 1 ? '' : 's'} failed${area ? `, and the hardest area is "${area}"` : ''}.` : area ? `No check is failing, but the last points are missing, mostly in "${area}".` : `No check is failing${partial ? `; ${partial} check${partial === 1 ? ' is' : 's are'} only partly passed, listed below` : ''}.`,
|
|
80
80
|
verdict: (openN, mech, people = 'buyers') => openN
|
|
81
81
|
? `${openN} check${openN === 1 ? ' is' : 's are'} not fully passed. ${mech} of them are mechanical, fixable without rewriting a word, which means quickly; the other ${openN - mech} need your facts: figures, sources, answers to what ${people} ask.`
|
|
82
82
|
: `The point: nothing is broken. From here the site grows through text, not repair: answers to what ${people} ask, figures, and the sources behind them.`,
|
|
@@ -87,7 +87,7 @@ const T = {
|
|
|
87
87
|
contactYes: 'getting in touch is possible from almost any page',
|
|
88
88
|
contactNo: 'most pages offer no way to get in touch, so a visitor has to go looking',
|
|
89
89
|
strengthNone: 'The checks found no strength in the text.',
|
|
90
|
-
weakNone: '
|
|
90
|
+
weakNone: 'No weakness found in the text of the existing pages.',
|
|
91
91
|
gapsNone: 'No gaps are visible across the sampled pages.',
|
|
92
92
|
aeoWorksNone: 'Nothing that helps a page get quoted is working yet.',
|
|
93
93
|
aeoBlocksNone: 'Nothing directly blocks a page from being quoted.',
|
|
@@ -99,7 +99,7 @@ const T = {
|
|
|
99
99
|
geoCallout: (ok) => ok
|
|
100
100
|
? 'The fetchers that pull a page in order to quote it in an answer are allowed in. That is half the job: the rest is whether there is anything worth quoting.'
|
|
101
101
|
: 'The fetchers that pull a page to quote it in an AI answer are blocked. While that is true, being quoted is physically impossible, whatever you write.',
|
|
102
|
-
offpageListed: (profiles) => profiles.length ?
|
|
102
|
+
offpageListed: (profiles) => profiles.length ? '' : 'The site declares no company profile anywhere else.',
|
|
103
103
|
offpageNote: 'We do not buy link data, so we print no number of links and no score built on one. What is checkable for free: whether the site says where else to find you, and whether that matches reality.',
|
|
104
104
|
priceRow: 'Price visible',
|
|
105
105
|
priceNo: 'no price on the pages we read',
|
|
@@ -124,12 +124,13 @@ const listRu = (a0) => { const a = a0.map(lowerMid); return a.length < 2 ? a[0]
|
|
|
124
124
|
const listEn = (a) => (a.length < 2 ? a[0] || '' : sep(a) === '; ' ? a.join('; ') : `${a.slice(0, -1).join(', ')} and ${a[a.length - 1]}`);
|
|
125
125
|
|
|
126
126
|
/** Проверки, которые Fix закрывает технически: ими меряется «сколько чинится быстро». */
|
|
127
|
-
const MECHANICAL = new Set(['robots', 'robots-block', 'ai-search-access', 'sitemap', 'sitemap-foreign', 'sitemap-health', 'llms', 'title', 'description', 'h1', 'canonical', 'viewport', 'og', 'schema', 'faq-schema', 'org-schema', 'hreflang', 'xmlrpc', 'alt', 'h1-sample', 'dup-titles', 'utility', 'dates', 'trust-entity', 'trust-contact', 'trust-
|
|
127
|
+
const MECHANICAL = new Set(['ttfb', 'sections', 'robots', 'robots-block', 'ai-search-access', 'sitemap', 'sitemap-foreign', 'sitemap-health', 'llms', 'title', 'description', 'h1', 'canonical', 'viewport', 'og', 'schema', 'faq-schema', 'org-schema', 'hreflang', 'xmlrpc', 'alt', 'h1-sample', 'dup-titles', 'utility', 'dates', 'trust-entity', 'trust-contact', 'trust-policies', 'conv-contact-path', 'conv-cta', 'conv-messenger', 'www', 'resources', 'conv-form-depth', 'conv-form-fields']);
|
|
128
128
|
|
|
129
129
|
import { vocabFor } from './niche.mjs';
|
|
130
130
|
import { localiseChecks, AREAS_RU } from './i18n.mjs';
|
|
131
131
|
import { FIX_ACTIONS } from './fix.mjs';
|
|
132
132
|
import { snapshotFacts } from './facts.mjs';
|
|
133
|
+
import { isListPage } from './pagekind.mjs';
|
|
133
134
|
|
|
134
135
|
const byId = (checks, id) => (checks || []).find((c) => c.id === id);
|
|
135
136
|
const open = (c) => c && (c.status === 'warn' || c.status === 'bad');
|
|
@@ -150,7 +151,8 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
150
151
|
// Отчёт по-русски по английскому аудиту: названия проверок и областей переводим здесь же,
|
|
151
152
|
// иначе владелец читает русский текст с английскими вставками и перестаёт нам верить.
|
|
152
153
|
const needsRu = lang === 'ru' && a.meta?.lang !== 'ru';
|
|
153
|
-
|
|
154
|
+
// Русский отчёт всегда читает переведённые строки: «Сейчас: 1: t.me» в приоритетах был сырым значением (cimes.ru, 14.10.2026).
|
|
155
|
+
const checks = lang === 'ru' ? localiseChecks(a.checks || [], 'ru') : (a.checks || []);
|
|
154
156
|
/*
|
|
155
157
|
* Названия областей переводим по языку ОТЧЁТА, а не по языку исходного аудита.
|
|
156
158
|
*
|
|
@@ -167,9 +169,15 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
167
169
|
|
|
168
170
|
const bad = checks.filter((c) => c.status === 'bad');
|
|
169
171
|
const warn = checks.filter((c) => c.status === 'warn');
|
|
170
|
-
|
|
172
|
+
// Карточка, адрес которой уже известен (listings), вписывается в разметку без фактов владельца: это техническая правка (cimes.ru, 15.10.2026).
|
|
173
|
+
const isMech = (c) => MECHANICAL.has(c.id) || (c.id === 'trust-profiles' && c.listings?.length > 0);
|
|
174
|
+
const mechanical = [...bad, ...warn].filter(isMech).length;
|
|
171
175
|
const scores = Object.entries(a.scores || {}).filter(([, v]) => typeof v === 'number');
|
|
172
|
-
|
|
176
|
+
// «Хорошо сделано» только для области без провалов и не больше чем с одной частичной проверкой: иначе её проблемы
|
|
177
|
+
// стоят в приоритетах, и абзац называет одну область и сильной, и главной бедой (cimes.ru, 14.10.2026).
|
|
178
|
+
const AREA_GROUPS = { 'SEO, technical': ['technical'], 'SEO, content and structure': ['onpage', 'content'], 'AEO, answers and snippets': ['aeo'], 'GEO, visibility in AI systems': ['geo'], 'Off-page and trust': ['offpage'], 'Conversion and UX': ['conversion'] };
|
|
179
|
+
const clean = (k) => { const g = AREA_GROUPS[k] || []; const own = checks.filter((c) => g.includes(c.group)); return !own.some((c) => c.status === 'bad') && own.filter((c) => c.status === 'warn').length <= 1; };
|
|
180
|
+
const strong = scores.filter(([k, v]) => v >= 8 && clean(k)).map(([k]) => areaName(k));
|
|
173
181
|
const weakest = areaName(scores.slice().sort((x, y) => x[1] - y[1])[0]?.[0] || '');
|
|
174
182
|
|
|
175
183
|
// Кто клиент. Имя это адрес сайта, предмет прочитан с главной и помечен как прочитанный.
|
|
@@ -180,7 +188,13 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
180
188
|
// Резюме.
|
|
181
189
|
if (String(a.summary.lead).includes('{{')) {
|
|
182
190
|
// Число это выборка, а не размер сайта: «сайт из 19 страниц» про магазин на сотни товаров неправда (12.10.2026).
|
|
183
|
-
|
|
191
|
+
// Слабейшая область, которая сама в «хорошо сделано» (оценка 8 и выше), слабой не называется: абзац противоречил себе (cimes.ru, 14.10.2026).
|
|
192
|
+
const weakestScore = scores.slice().sort((x, y) => x[1] - y[1])[0]?.[1];
|
|
193
|
+
// «Прочитали 20» и «ни на одной из 150 прочитанных» рядом без объяснения путали владельца (cimes.ru, 14.10.2026).
|
|
194
|
+
const readAll = Number(a.verification?.pagesRead) || 0;
|
|
195
|
+
const smTotal = Number((String(byId(checks, 'sitemap')?.value || '').match(/(\d+)/) || [])[1]) || 0;
|
|
196
|
+
const more = readAll > sample.length ? (lang === 'ru' ? `Кроме того, чтобы проверить каждое «нет на сайте», мы открыли ${readAll}${smTotal > readAll ? ` из ${smTotal}` : ''} ${plural(readAll, 'адрес', 'адреса', 'адресов').replace(/^\d+ /, '')} карты сайта (${sample.length} страниц выборки входят в это число).` : `We also read ${readAll} pages from the sitemap to check every "not on the site".`) : '';
|
|
197
|
+
a.summary.lead = [t.leadWhat(host, sample.length), more, t.leadGood(strong), t.leadBad(weakestScore >= 8 ? '' : weakest, bad.length, warn.length)].join(' ');
|
|
184
198
|
}
|
|
185
199
|
if (String(a.summary.verdict).includes('{{')) a.summary.verdict = t.verdict(bad.length + warn.length, mechanical, vocabFor(a, lang === 'ru' ? 'ru' : 'en').peopleGen);
|
|
186
200
|
// Приоритет это не название проверки, а действие: владелец должен прочитать и понять, что делать.
|
|
@@ -204,10 +218,17 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
204
218
|
const ru = lang === 'ru'; const v = String(c.value || '');
|
|
205
219
|
if (c.id === 'description' && /нет|no meta description|missing|none/i.test(v)) return ru ? 'Написать описание главной в 120-160 знаков' : 'Write a home page description of 120 to 160 characters';
|
|
206
220
|
if (c.id === 'h1' && /нет|no H1/i.test(v)) return ru ? 'Добавить на главную один заголовок H1 о том, чем занимается компания' : 'Add one H1 to the home page saying what the business does';
|
|
221
|
+
// Какие страницы разбивать, называем: «разбить страницу» без адреса владельцу не говорит, о какой речь (cimes.ru, 14.10.2026).
|
|
222
|
+
if (c.id === 'answer-first') { const miss = (a.sample || []).filter((p) => p && p.title !== undefined && !p.answerFirst && !isListPage(p)).map((p) => { try { return new URL(p.url).pathname; } catch { return p.url; } }); if (miss.length) return ru ? `Поставить в начало страниц прямой ответ с цифрой, начиная с ${miss.slice(0, 3).join(', ')} (всего таких страниц с текстом в выборке ${miss.length}, список в файле задач; страницы-списки переписывать не нужно)` : `Put a direct answer with a figure at the top of the pages, starting with ${miss.slice(0, 3).join(', ')} (${miss.length} such sampled pages, listed in the tasks file)`; }
|
|
223
|
+
if (c.id === 'trust-profiles' && c.listings && c.listings.length) return ru ? `Указать в разметке карточку компании, которая уже показана на сайте: ${c.listings[0]}` : `Declare in the markup the company listing the site already shows: ${c.listings[0]}`;
|
|
224
|
+
if (c.id === 'trust-profiles' && c.messengersOnly) return ru ? 'Найти и указать в разметке настоящие профили компании на площадках' : 'Find and declare the company\'s real platform profiles in the markup';
|
|
225
|
+
if (c.id === 'trust-about' && c.status === 'bad') return ru ? 'Сделать страницу «О компании» и поставить ссылку на неё в меню или подвал' : 'Make an About page and link it from the menu or footer';
|
|
226
|
+
if (c.id === 'sections') { const long = (a.sample || []).filter((p) => p && p.title !== undefined && (p.words || 0) >= 600 && (p.h2Count || 0) < 3 && !isListPage(p)).slice(0, 3).map((p) => { try { return new URL(p.url).pathname; } catch { return p.url; } }); const few = Number((String(c.value).match(/(\d+) из|(\d+) of/) || [])[1] || 0); const rest = (a.sample || []).filter((p) => p && p.title !== undefined && (p.h2Count || 0) < 3 && !long.some((x) => p.url.endsWith(x))); const isList = isListPage; const nList = rest.filter(isList).length; const nShort = rest.length - nList; const why = few > long.length ? (ru ? ` (остальные ${nShort && nList ? 'короткие или это страницы-списки' : nList ? 'это страницы-списки' : 'короткие'}, их разбивать не нужно)` : ` (the others are ${nShort && nList ? 'short or list pages' : nList ? 'list pages' : 'short'})`) : ''; if (long.length) return ru ? `Разбить на разделы длинные страницы: ${long.join(', ')}${why}` : `Split the long pages into sections: ${long.join(', ')}${why}`; }
|
|
207
227
|
if (c.id === 'alt') { const sm = (a.sample || []).filter((p) => p && p.title !== undefined); if (sm.length && sm.every((p) => p.imagesAltMissing === 0)) return ru ? 'Вписать подписи к фотографиям по их содержимому, значкам оформления оставить пустой alt' : 'Write alt text for the photos, leave decorative icons with an empty alt'; }
|
|
208
228
|
return null;
|
|
209
229
|
};
|
|
210
|
-
|
|
230
|
+
// Пункт приоритета это законченное предложение: с точкой в конце (cimes.ru, 15.10.2026).
|
|
231
|
+
const action = (c) => `${measuredAction(c) || actionText(c)}. ${lang === 'ru' ? 'Сейчас' : 'Now'}: ${String(c.value).replace(/[.\s]+$/, '')}.`;
|
|
211
232
|
// В приоритеты идут только те находки, для которых есть действие. У части проверок в FIX_ACTIONS
|
|
212
233
|
// вместо действия стоит довод против («это имеет смысл только тем, у кого правда есть API»), и
|
|
213
234
|
// такая строка первым пунктом плана читается как отговорка, а не как задача.
|
|
@@ -221,11 +242,25 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
221
242
|
const c = top[i];
|
|
222
243
|
return c ? action(c) : (lang === 'ru' ? 'Ничего срочного больше не нашли' : 'Nothing else is urgent');
|
|
223
244
|
});
|
|
245
|
+
// Один список «что первым»: три приоритета, раздел «Что чинить первым» и первая неделя плана называют одно и то же.
|
|
246
|
+
// Раньше это были три разных списка, и владелец не понимал, с чего начинать (cimes.ru, 14.10.2026).
|
|
247
|
+
if (top.length) {
|
|
248
|
+
a.priorityIds = top.map((c) => c.id);
|
|
249
|
+
const fixWord = lang === 'ru' ? 'Исправить' : 'Fix';
|
|
250
|
+
a.critical = top.map((c) => ({ title: c.label, text: [c.value, c.comment ? `${String(c.comment)[0].toUpperCase()}${String(c.comment).slice(1)}` : ''].filter(Boolean).join('. '), level: c.status === 'bad' ? 'bad' : 'warn' }));
|
|
251
|
+
const w1 = (a.roadmap || [])[0];
|
|
252
|
+
if (w1 && /Неделя 1|Week 1/.test(String(w1.badge))) {
|
|
253
|
+
w1.items = top.map((c) => `${fixWord}: ${c.label}`);
|
|
254
|
+
const w2 = a.roadmap[1];
|
|
255
|
+
if (w2 && Array.isArray(w2.items)) w2.items = w2.items.filter((i) => !top.some((c) => String(i).includes(c.label)));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
224
258
|
|
|
225
259
|
// Обзор.
|
|
226
260
|
a.overview.rows = (a.overview.rows || []).map(([k, v]) => {
|
|
227
261
|
if (!String(v).includes('{{')) return [k, v];
|
|
228
|
-
|
|
262
|
+
// «ru» владелец не читает: название языка словом (cimes.ru, 14.10.2026).
|
|
263
|
+
if (/language|язык/i.test(k)) { const code = String(a.meta?.language || '').toLowerCase().slice(0, 2); const RU_NAMES = { ru: 'русский', en: 'английский', uk: 'украинский', kk: 'казахский', de: 'немецкий', fr: 'французский', es: 'испанский', it: 'итальянский', th: 'тайский', tr: 'турецкий', ar: 'арабский', zh: 'китайский' }; const EN_NAMES = { ru: 'Russian', en: 'English', uk: 'Ukrainian', de: 'German', fr: 'French', es: 'Spanish', it: 'Italian', th: 'Thai', tr: 'Turkish', ar: 'Arabic', zh: 'Chinese' }; return [k, code ? ((lang === 'ru' ? RU_NAMES : EN_NAMES)[code] || a.meta.language) : t.unknownLang]; }
|
|
229
264
|
if (/sold|продаётся|продается|чем занимается/i.test(k)) return [k, t.sells(desc, String(home.offerSentence || home.firstPara || '').trim().slice(0, 220).replace(/[\s.,;:]+$/, ''))];
|
|
230
265
|
return [k, t.notMeasured];
|
|
231
266
|
});
|
|
@@ -237,13 +272,21 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
237
272
|
|
|
238
273
|
// Контент, AEO, GEO, ссылки, конверсия: строки собираются из своих же групп проверок.
|
|
239
274
|
const group = (g) => checks.filter((c) => c.group === g);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
275
|
+
// Строк в шаблоне меньше, чем найденного: остальное дописываем (до четырёх), иначе «Что мешает» молчит о второй проблеме (cimes.ru, 14.10.2026).
|
|
276
|
+
const fillList = (arr, source, empty) => {
|
|
277
|
+
const slots = (arr || []).filter((x) => String(x).includes('{{')).length;
|
|
278
|
+
const out = (arr || []).map((x, i) => {
|
|
279
|
+
if (!String(x).includes('{{')) return x;
|
|
280
|
+
const c = source[i];
|
|
281
|
+
return c ? `${c.label}: ${c.value}` : empty;
|
|
282
|
+
});
|
|
283
|
+
if (slots && source.length > slots) out.push(...source.slice(slots, 4).map((c) => `${c.label}: ${c.value}`));
|
|
284
|
+
return out;
|
|
285
|
+
};
|
|
245
286
|
a.content.strengths = fillList(a.content.strengths, group('content').filter((c) => c.status === 'ok'), t.strengthNone);
|
|
246
|
-
|
|
287
|
+
// Слабые места текстов могут быть в разделе ответов ИИ (первый абзац, разделы): тогда «не нашли» здесь противоречит ему (cimes.ru, 14.10.2026).
|
|
288
|
+
const aeoOpen = group('aeo').filter(open);
|
|
289
|
+
a.content.weaknesses = fillList(a.content.weaknesses, group('content').filter(open), aeoOpen.length ? (lang === 'ru' ? `Слабые места текстов относятся к ответам ИИ и описаны ниже, в разделе «Ответные системы: AEO»: ${aeoOpen.slice(0, 3).map((c) => c.label.toLowerCase()).join(', ')}.` : `The text weaknesses concern AI answers and are covered in that section below: ${aeoOpen.slice(0, 3).map((c) => c.label.toLowerCase()).join(', ')}.`) : t.weakNone);
|
|
247
290
|
// «Структурные дыры» это страницы, которых нет, а не проваленные проверки текста (14.10.2026: туда попадали источники цифр).
|
|
248
291
|
{
|
|
249
292
|
const ru = lang === 'ru'; const miss = [];
|
|
@@ -258,7 +301,8 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
258
301
|
a.aeo.works = fillList(a.aeo.works, group('aeo').filter((c) => c.status === 'ok'), t.aeoWorksNone);
|
|
259
302
|
a.aeo.blocks = fillList(a.aeo.blocks, group('aeo').filter(open), t.aeoBlocksNone);
|
|
260
303
|
if (String(a.aeo.recommendation).includes('{{')) {
|
|
261
|
-
|
|
304
|
+
// Та же первая страница, что в приоритете и в задаче: три места советовали начать с разных (cimes.ru, 15.10.2026).
|
|
305
|
+
const thinnest = sample.filter((p) => p.answerFirst === false && !isListPage(p))[0] || sample.filter((p) => p.answerFirst === false)[0];
|
|
262
306
|
a.aeo.recommendation = t.aeoRec(thinnest ? thinnest.url : '');
|
|
263
307
|
}
|
|
264
308
|
|
|
@@ -276,10 +320,13 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
276
320
|
}
|
|
277
321
|
|
|
278
322
|
const profilesCheck = byId(checks, 'trust-profiles');
|
|
279
|
-
|
|
323
|
+
// Профиль в разметке есть и при статусе «частично» (один профиль): «ни одного» рядом с «1: t.me» было противоречием (cimes.ru, 14.10.2026).
|
|
324
|
+
const profiles = profilesCheck && /^(\d+:|указан|\d+ profiles?:)/.test(String(profilesCheck.value)) ? String(profilesCheck.value).split(/[,:]/).slice(1).map((s) => s.trim()).filter(Boolean) : [];
|
|
280
325
|
// Ссылки на профили на самих страницах (второй взгляд их видит) тоже профиль, просто не в разметке (14.10.2026).
|
|
281
326
|
const linkedOnly = profilesCheck && /ссылк\S* на сайте есть|ссылка на канал|pages link to/i.test(String(profilesCheck.value));
|
|
282
|
-
|
|
327
|
+
// Карточка показана на сайте, но не в разметке, или в разметке одни мессенджеры: «ни одного профиля» было бы неправдой (cimes.ru, 15.10.2026).
|
|
328
|
+
const listedNote = profilesCheck?.listings?.length ? (lang === 'ru' ? `Карточка компании ${profilesCheck.listings[0]} показана на сайте, но в разметке не указана.` : `The company listing ${profilesCheck.listings[0]} is shown on the site but not declared in the markup.`) : profilesCheck?.messengersOnly?.length ? (lang === 'ru' ? 'В разметке указаны только мессенджеры, профилей на площадках нет.' : 'The markup lists only messengers, no platform profiles.') : '';
|
|
329
|
+
a.offpage.listed = (a.offpage.listed || []).map((x) => String(x).includes('{{') ? (linkedOnly ? (lang === 'ru' ? 'В разметке профили не заявлены, но на сайте есть ссылки на свои профили: их стоит вписать в sameAs.' : 'The markup declares no profiles, but the pages link to the company profiles: put them into sameAs.') : (listedNote || t.offpageListed(profiles))) : x);
|
|
283
330
|
if (String(a.offpage.note).includes('{{')) a.offpage.note = t.offpageNote;
|
|
284
331
|
|
|
285
332
|
a.conversion.rows = (a.conversion.rows || []).map(([label, status]) => {
|
|
@@ -303,15 +350,15 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
303
350
|
const labelToCheck = new Map(checks.map((c) => [c.label, c]));
|
|
304
351
|
const englishLabel = new Map((a.checks || []).map((c, i) => [c.label, checks[i]]));
|
|
305
352
|
const retitle = (item) => {
|
|
306
|
-
const m = /^Fix:\s*(.+)$/.exec(String(item));
|
|
353
|
+
const m = /^(?:Fix|Исправить):\s*(.+)$/.exec(String(item));
|
|
307
354
|
if (!m) return item;
|
|
308
355
|
const c = labelToCheck.get(m[1]) || englishLabel.get(m[1]);
|
|
309
|
-
return c ? actionText(c) : item;
|
|
356
|
+
return c ? (measuredAction(c) || actionText(c)) : item;
|
|
310
357
|
};
|
|
311
358
|
a.roadmap = (a.roadmap || []).map((phase, pi) => ({
|
|
312
359
|
...phase,
|
|
313
360
|
items: (phase.items || []).map((item, ii) => {
|
|
314
|
-
if (!String(item).includes('{{')) return
|
|
361
|
+
if (!String(item).includes('{{')) return retitle(item);
|
|
315
362
|
const pool = pi === 0 ? mech : pi === 1 ? textWork : [];
|
|
316
363
|
const c = pool[ii];
|
|
317
364
|
if (c) return actionText(c);
|
|
@@ -319,7 +366,8 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
319
366
|
}),
|
|
320
367
|
}));
|
|
321
368
|
|
|
322
|
-
|
|
369
|
+
// «Технически в порядке, чинить нечего» только когда открытых технических проверок нет совсем, частичных тоже (cimes.ru, 14.10.2026).
|
|
370
|
+
if (String(a.closing).includes('{{')) a.closing = t.closing(host, bad.length + mechanical);
|
|
323
371
|
// Что дали данные в каждом поле с числами: по этому снимку проверка кодом ловит чужие числа (facts.mjs, 14.10.2026).
|
|
324
372
|
a.facts = snapshotFacts(a, lang);
|
|
325
373
|
return a;
|
package/src/pagekind.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Вид страницы, одно правило на весь движок (15.10.2026, cimes.ru). Страница-список (CollectionPage, ItemList)
|
|
3
|
+
* разводит по подразделам: переписывать её начало и разбивать на разделы не нужно. Раньше задача про разделы
|
|
4
|
+
* признавала /services/integration/ списком, а задача про первый абзац тут же требовала его переписать.
|
|
5
|
+
*/
|
|
6
|
+
export const isListPage = (p) => (p?.schemaTypes || []).some((t) => /^(CollectionPage|ItemList)$/.test(t));
|