@operstack/audit 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fix.mjs CHANGED
@@ -20,11 +20,12 @@
20
20
  */
21
21
  import { localiseChecks } from './i18n.mjs';
22
22
  import { agentPrompt } from './prompts.mjs';
23
+ import { vocabFor } from './niche.mjs';
23
24
 
24
25
  /** Что мы делаем по каждой проверке. Без этого «закроем» это слово, а не работа. */
25
26
  export const FIX_ACTIONS = {
26
27
  https: { scope: 'never', why: { ru: 'сертификат выпускает хостинг, это не наша работа', en: 'the certificate is issued by the host, not by us' } },
27
- www: { scope: 'always', action: { ru: 'Поставить постоянную переадресацию с www на основной адрес и проверить кодом ответа', en: 'Add a permanent redirect from www to the main address and confirm it by response code' } },
28
+ www: { scope: 'always', action: { ru: 'Поставить постоянную переадресацию с www на основной адрес и проверить по коду ответа', en: 'Add a permanent redirect from www to the main address and confirm it by response code' } },
28
29
  ttfb: { scope: 'never', why: { ru: 'время ответа определяется хостингом клиента, обещать его нельзя', en: 'response time is set by your hosting, so we cannot promise it' } },
29
30
  robots: { scope: 'always', action: { ru: 'Переписать robots.txt: убрать лишние запреты, добавить строку с картой сайта', en: 'Rewrite robots.txt: drop the needless blocks, add the sitemap line' } },
30
31
  'robots-block': { scope: 'always', action: { ru: 'Снять Disallow: / из блока для всех роботов', en: 'Remove Disallow: / from the all-robots block' } },
@@ -33,7 +34,7 @@ export const FIX_ACTIONS = {
33
34
  sitemap: { scope: 'always', action: { ru: 'Починить или сгенерировать карту сайта и сослаться на неё из robots.txt', en: 'Fix or generate the sitemap and point robots.txt at it' } },
34
35
  'sitemap-foreign': { scope: 'always', action: { ru: 'Убрать из карты сайта адреса на чужих хостах', en: 'Remove URLs on other hosts from the sitemap' } },
35
36
  'sitemap-health': { scope: 'always', action: { ru: 'Вычистить из карты сайта адреса, которые отвечают редиректом или ошибкой', en: 'Clear out sitemap URLs that answer with a redirect or an error' } },
36
- llms: { scope: 'always', action: { ru: 'Сгенерировать llms.txt из корпуса так, чтобы ссылки вели на страницы самого сайта', en: 'Generate llms.txt from the corpus so every link points at a page of this site' } },
37
+ llms: { scope: 'always', action: { ru: 'Собрать llms.txt по страницам самого сайта, короткую карту для систем ИИ, где все ссылки ведут на ваш сайт', en: 'Build llms.txt from the site\'s own pages, a short map for AI assistants that links only to your site' } },
37
38
  title: { scope: 'always', action: { ru: 'Переписать заголовок главной в пределах 60 знаков', en: 'Rewrite the home page title within 60 characters' } },
38
39
  description: { scope: 'always', action: { ru: 'Переписать описание главной, убрать служебный код, уложиться в выдачу', en: 'Rewrite the home page description, strip leftover markup, fit the snippet' } },
39
40
  h1: { scope: 'always', action: { ru: 'Оставить на главной ровно один заголовок H1', en: 'Leave exactly one H1 on the home page' } },
@@ -43,7 +44,7 @@ export const FIX_ACTIONS = {
43
44
  schema: { scope: 'always', action: { ru: 'Добавить разметку организации и сайта на главную', en: 'Add Organization and WebSite markup to the home page' } },
44
45
  'faq-schema': { scope: 'always', action: { ru: 'Разметить блоки вопросов и ответов схемой FAQPage', en: 'Mark up question and answer blocks with FAQPage' } },
45
46
  'org-schema': { scope: 'always', action: { ru: 'Добавить разметку организации с адресом и профилями', en: 'Add Organization markup with the address and the profiles' } },
46
- resources: { scope: 'files', action: { ru: 'Сократить число подключаемых файлов и включить годовой кеш для статики', en: 'Cut the number of linked files and set a year-long cache for static assets' } },
47
+ resources: { scope: 'files', action: { ru: 'Сократить число подключаемых файлов, объединить скрипты и стили и убрать неиспользуемые', en: 'Cut the number of linked files: merge scripts and styles, drop unused ones' } },
47
48
  hreflang: { scope: 'always', action: { ru: 'Проставить hreflang, если у сайта несколько языков', en: 'Set hreflang where the site has more than one language' } },
48
49
  xmlrpc: { scope: 'always', action: { ru: 'Закрыть открытую точку xmlrpc.php', en: 'Close the open xmlrpc.php endpoint' } },
49
50
  alt: { scope: 'always', action: { ru: 'Проставить подписи ко всем картинкам по их содержимому', en: 'Write alt text for every image from what it actually shows' } },
@@ -57,7 +58,7 @@ export const FIX_ACTIONS = {
57
58
  'trust-about': { scope: 'always', action: { ru: 'Сделать страницу о компании доступной со всех страниц', en: 'Make the about page reachable from every page' } },
58
59
  'trust-policies': { scope: 'always', action: { ru: 'Добавить политику конфиденциальности и условия и сослаться на них из подвала', en: 'Add a privacy policy and terms and link them from the footer' } },
59
60
  'conv-contact-path': { scope: 'always', action: { ru: 'Дать способ связаться с любой страницы', en: 'Give a way to get in touch from any page' } },
60
- 'conv-cta': { scope: 'always', action: { ru: 'Поставить призыв к действию в первый экран', en: 'Put a call to action in the first screen' } },
61
+ 'conv-cta': { scope: 'always', action: { ru: 'Поставить призыв к действию на первый экран', en: 'Put a call to action in the first screen' } },
61
62
  'conv-form-depth': { scope: 'files', action: { ru: 'Поднять форму выше по странице', en: 'Move the form higher up the page' } },
62
63
  'conv-form-fields': { scope: 'files', action: { ru: 'Сократить форму до четырёх полей', en: 'Cut the form down to four fields' } },
63
64
  'conv-messenger': { scope: 'always', action: { ru: 'Добавить ссылку на мессенджер', en: 'Add a messenger link' } },
@@ -157,10 +158,13 @@ const pick = (field, lang) => (field && typeof field === 'object' ? field[lang =
157
158
  * @param {object} audit результат collect()
158
159
  * @param {{platform?: 'files'|'cms', price?: number, currency?: string, lang?: 'ru'|'en'}} opts
159
160
  */
161
+ /** Цена Fix с живых страниц (oper-stack.ru/produkty/: 21 000 ₽; oper-stack.com/pricing/: $249), 15.10.2026. 12 500 ₽ это аудит. */
162
+ export const FIX_PRICE = { ru: 21000, en: 249 };
163
+ export const fixPrice = (lang) => FIX_PRICE[lang === 'en' ? 'en' : 'ru'];
160
164
  export function buildFixPlan(audit, opts = {}) {
161
165
  const platform = opts.platform === 'cms' ? 'cms' : 'files';
162
- const price = Number(opts.price ?? 249);
163
166
  const lang = opts.lang === 'en' ? 'en' : 'ru';
167
+ const price = Number(opts.price ?? fixPrice(lang));
164
168
  const currency = opts.currency || (lang === 'en' ? 'USD' : 'RUB');
165
169
  const t = dict(lang);
166
170
 
@@ -304,3 +308,118 @@ function plural(n, one, few, many) {
304
308
  const d = n % 10; const dd = n % 100;
305
309
  return d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 10 || dd >= 20) ? few : many;
306
310
  }
311
+
312
+ /*
313
+ * Строки письма про Fix (15.10.2026, прогон на своих сайтах). Письмо печатало «название проверки: сырое значение»
314
+ * («Открытые даты: 20 из 20 указывают дату в разметке», «across 20 sampled page(s)»), и читалось как «закроем то, что
315
+ * уже в порядке». Здесь каждая строка говорит, что именно сделаем, по замеру, где он есть; плюс сколько находок не
316
+ * входит в работу и какие права нужны под этот список, а не вообще.
317
+ */
318
+ const FILE_WORK = new Set(['viewport', 'robots', 'robots-block', 'ai-search-access', 'robots-ai', 'sitemap', 'sitemap-foreign', 'sitemap-health', 'llms', 'llms-health', 'www', 'xmlrpc', 'utility', 'resources', 'agent-card', 'agent-markdown', 'api-catalog', 'conv-form-depth', 'conv-form-fields', 'js-content']);
319
+ const numsOf = (v) => (String(v || '').match(/\d+/g) || []).map(Number);
320
+ // Пункты русского письма про Fix в одной форме, «сделаем»: «переписать» рядом с «допишем» читалось как два автора
321
+ // (moregroupestate.ru, прогон на 20 сайтах 15.10.2026).
322
+ const WE = { поставить: 'поставим', переписать: 'перепишем', снять: 'снимем', открыть: 'откроем', зафиксировать: 'зафиксируем', починить: 'починим', убрать: 'уберём', вычистить: 'вычистим', сгенерировать: 'сгенерируем', оставить: 'оставим', проставить: 'проставим', задать: 'зададим', добавить: 'добавим', разметить: 'разметим', сократить: 'сократим', закрыть: 'закроем', свести: 'сведём', сослаться: 'сошлёмся', убедиться: 'убедимся', заняться: 'займёмся', связаться: 'свяжемся', развести: 'разведём', исключить: 'исключим', вывести: 'выведем', опубликовать: 'опубликуем', указать: 'укажем', сделать: 'сделаем', дать: 'дадим', поднять: 'поднимем', пересобрать: 'пересоберём', положить: 'положим', отдавать: 'будем отдавать', вписать: 'впишем', написать: 'напишем', перенести: 'перенесём', заменить: 'заменим', удалить: 'удалим', найти: 'найдём', дописать: 'допишем', показать: 'покажем', настроить: 'настроим', исправить: 'исправим', вынести: 'вынесем', объединить: 'объединим', подключить: 'подключим', включить: 'включим', перевести: 'переведём', собрать: 'соберём', прописать: 'пропишем', привести: 'приведём', разбить: 'разобьём', свести: 'сведём', перенести: 'перенесём', привести: 'приведём', вывести: 'выведем', развести: 'разведём', провести: 'проведём', проверить: 'проверим', подтвердить: 'подтвердим', сжать: 'сожмём', ускорить: 'ускорим', отключить: 'отключим', разрешить: 'разрешим', запретить: 'запретим', вернуть: 'вернём', сменить: 'сменим', связать: 'свяжем', объявить: 'объявим', убедиться: 'убедимся', перестать: 'перестанем', отдать: 'отдадим', держать: 'будем держать', ставить: 'будем ставить' };
323
+ // Каждый глагол списка, а не только первый: «сократим … и включить годовой кеш» (проверяющий, 15.10.2026).
324
+ export const weForm = (t) => String(t || '').replace(/(?<![А-Яа-яЁё])[А-Яа-яЁё]+(?:ть|ти|чь)(?:ся|сь)?(?![А-Яа-яЁё])/g, (w) => { const x = WE[w.toLowerCase()]; return x ? (w[0] === w[0].toUpperCase() ? x[0].toUpperCase() + x.slice(1) : x) : w; });
325
+ // Дату показывают у материалов (статьи, новости, гайды, кейсы), а не у служебных, юридических и страниц-списков
326
+ // (проверяющий: «какую дату показывать на /about/, /contact/, /terms/?», 15.10.2026).
327
+ const ARTICLE_TYPE = /^(Article|BlogPosting|NewsArticle|TechArticle|HowTo|Report|ScholarlyArticle)$/;
328
+ const ARTICLE_PATH = /\/(blog|news|novosti|stati|statya|articles?|guides?|gajdy|gaid|insights|journal|zhurnal|media|cases?|keisy|posts?)\/[^/]+/i;
329
+ const LEGAL = /privacy|terms|cookie|policy|politika|soglas|oferta|legal|impressum/i;
330
+ const articleLike = (p) => { let path = ''; try { path = new URL(p.url).pathname; } catch { path = ''; } return !LEGAL.test(path) && ((p.schemaTypes || []).some((t) => ARTICLE_TYPE.test(t)) || ARTICLE_PATH.test(path)); };
331
+ const pathsOf = (pages) => pages.map((p) => { try { const x = new URL(p.url).pathname; try { return decodeURIComponent(x); } catch { return x; } } catch { return p.url; } });
332
+ function measuredFixAction(c, ru, audit = {}) {
333
+ const v = String(c.value || ''); const n = numsOf(v);
334
+ // Глубина формы (прогон на 20 сайтах, 15.10.2026): формы нет совсем, значит поднимать нечего, а добавить форму уже
335
+ // обещает строка «Способ связаться»; форма во всплывающем окне по кнопке тоже не «глубоко». Иначе называем страницы.
336
+ if (c.id === 'trust-entity' && /only the city and country|только город и страна|без улицы/i.test(v)) { const pg = (audit.sample || []).find((p) => p && p.addressText); if (pg) return ru ? `перенесём в разметку организации адрес, который уже написан на сайте: «${pg.addressText}»` : `copy into the organisation markup the address the site already shows: "${pg.addressText}"`; }
337
+ if (c.id === 'conv-form-depth') {
338
+ if (/pop-up|всплыва/i.test(v)) return null;
339
+ if (/no form|формы нет/i.test(v)) return null;
340
+ const deep = (audit.sample || []).filter((p) => p && p.formDepth >= 50).map((p) => { try { return new URL(p.url).pathname; } catch { return p.url; } });
341
+ if (deep.length) return ru ? `поднимем форму выше или поставим вверху кнопку к ней на страницах, где форма лежит ниже середины: ${deep.slice(0, 3).join(', ')}${deep.length > 3 ? ` и ещё ${deep.length - 3}` : ''}` : `move the form up, or add a button near the top that leads to it, on the pages where the form sits below the middle: ${deep.slice(0, 3).join(', ')}${deep.length > 3 ? ` and ${deep.length - 3} more` : ''}`;
342
+ return ru ? `поднимем форму выше или поставим вверху кнопку к ней на страницах, где форма лежит ниже середины (сейчас в среднем на ${n[0] ?? '?'} процентах вниз)` : `move the form up, or add a button near the top that leads to it, on the pages where the form sits below the middle (now ${n[0] ?? '?'}% of the way down on average)`;
343
+ }
344
+ const low = (t) => t.charAt(0).toLowerCase() + t.slice(1);
345
+ 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';
346
+ if (c.id === 'description' && !/shortcode|garbage|служебный код/i.test(v) && n.length) return n[0] > 160 ? (ru ? `сократим описание до 160 знаков (сейчас ${n[0]})` : `shorten it to 160 characters (now ${n[0]})`) : n[0] < 120 ? (ru ? `дополним описание до 120-160 знаков (сейчас ${n[0]})` : `extend it to 120 to 160 characters (now ${n[0]})`) : null;
347
+ // Даты и путь к контакту: страницы по адресам и «из N проверенных страниц», а не голое «5 из 20» (проверяющий, 15.10.2026).
348
+ // Все страницы целиком: «и ещё 7» без адресов проверяющий читал как работу, за которую платят вслепую (15.10.2026).
349
+ const named = (pages) => { const x = pathsOf(pages); return x.length ? `: ${x.join(', ')}` : ''; };
350
+ // Дата уже видна в тексте («Last updated …»): показывать нечего, такую страницу не называем (проверяющий, 15.10.2026).
351
+ if (c.id === 'dates' && n.length >= 3) { const only = (audit.sample || []).filter((p) => p && p.dates && (p.dates.published || p.dates.modified) && !p.dates.visible && articleLike(p)); if (!only.length) return null; return ru ? `покажем дату публикации в тексте материалов, где она есть только в разметке${named(only)}` : `show the published date in the text of the articles that carry it only in the markup${named(only)}`; }
352
+ if (c.id === 'conv-contact-path' && n.length >= 2 && n[0] < n[1]) { const lack = (audit.sample || []).filter((p) => p && p.title !== undefined && p.contactReach === false); return ru ? `добавим ссылку на страницу контактов на ${n[1] - n[0] === 1 ? 'страницу, где её нет' : 'страницы, где её нет'}${named(lack) || ` (таких ${n[1] - n[0]} из ${n[1]} проверенных страниц)`}` : `add a link to the contact page on the ${n[1] - n[0] === 1 ? 'page that lacks one' : 'pages that lack one'}${named(lack) || ` (${n[1] - n[0]} of the ${n[1]} pages checked)`}`; }
353
+ if (c.id === 'title' && /\((\d+) (?:chars|знак)|^(\d+) знак/.test(v)) { const len = Number((v.match(/\((\d+) (?:chars|знак)|^(\d+) знак/) || []).slice(1).find(Boolean)); return ru ? `перепишем, сейчас ${len} ${len % 10 === 1 && len % 100 !== 11 ? 'знак' : [2, 3, 4].includes(len % 10) && ![12, 13, 14].includes(len % 100) ? 'знака' : 'знаков'}, станет 40-60, чтобы не обрезался в выдаче` : `rewrite it to 40 to 60 characters (now ${len}) so search results do not cut it off`; }
354
+ if (c.id === 'llms-health' && n.length) return ru ? 'уберём из llms.txt адреса, закрытые от индексации или не отвечающие' : 'remove from llms.txt the addresses closed to indexing or not answering';
355
+ // Форма: какую сокращать, по адресам страниц с длинной формой (проверяющий: «не сказано, какую форму», 15.10.2026).
356
+ if (c.id === 'conv-form-fields') { const long = (audit.sample || []).filter((p) => p && p.formFields > 4); return ru ? `сократим до четырёх полей форму на ${long.length === 1 ? 'странице' : 'страницах'}${named(long) || ' с длинной формой'}${n.length ? ` (сейчас в форме ${n[0]} ${n[0] % 10 === 1 && n[0] % 100 !== 11 ? 'поле' : [2, 3, 4].includes(n[0] % 10) && ![12, 13, 14].includes(n[0] % 100) ? 'поля' : 'полей'})` : ''}` : `cut the form to four fields on ${long.length === 1 ? 'the page' : 'the pages'}${named(long) || ' with a long form'}${n.length ? ` (${n[0]} fields now)` : ''}`; }
357
+ // Призыв на первом экране: называем страницы без кнопки. Без адресов владелец смотрел на главную, где кнопка есть.
358
+ if (c.id === 'conv-cta') {
359
+ // Юридическим страницам кнопка не нужна; действие называем по роду сайта (etochay.ru, 15.10.2026).
360
+ const miss = pathsOf((audit.sample || []).filter((p) => { if (!p || p.title === undefined || p.ctaFirstScreen !== false) return false; try { return !LEGAL.test(new URL(p.url).pathname); } catch { return true; } }));
361
+ if (!miss.length) return null;
362
+ const v = vocabFor(audit, ru ? 'ru' : 'en'); const act = v.niche && v.niche !== 'other' ? ` (${v.cta})` : '';
363
+ return ru ? `поставим на первый экран кнопку с действием${act} на страницах, где её нет: ${miss.join(', ')}` : `put an action button${act} in the first screen of the pages that lack one: ${miss.join(', ')}`;
364
+ }
365
+ // Конструктор: сборка и кеш у платформы, «объединим файлы и включим кеш» там сделать нельзя (Tilda, 15.10.2026).
366
+ const cms = String((audit.checks || []).find((x) => x.id === 'cms')?.value || '');
367
+ const hosted = (cms.match(/tilda|\bwix\b|squarespace|shopify/i) || [])[0];
368
+ if (hosted && c.id === 'resources') { const name = { tilda: 'Tilda', wix: 'Wix', squarespace: 'Squarespace', shopify: 'Shopify' }[hosted.toLowerCase()]; return ru ? `отключим в настройках ${name} неиспользуемые виджеты и вставки чужого кода, которые грузит главная` : `switch off, in the ${name} settings, the unused widgets and embedded code the home page loads`; }
369
+ if (hosted && c.id === 'js-content') return null;
370
+ // Какие страницы: из строки проверки берём худшую, без адреса пункт ни о чём (indatalabs.com, 15.10.2026).
371
+ if (c.id === 'js-content') { const w = (v.match(/хуже всего (\S+?):\s/) || v.match(/worst is (\S+) at\b/) || [])[1]; return w ? (ru ? `отдадим текст страниц сразу в HTML, а не скриптами, начиная с ${w}, где без скриптов не видно больше всего текста` : `serve page text in the HTML itself rather than through scripts, starting with ${w}, where the most text is hidden without scripts`) : null; }
372
+ const rule = FIX_ACTIONS[c.id];
373
+ return rule?.action ? (ru ? weForm(low(pick(rule.action, 'ru'))) : low(pick(rule.action, 'en'))) : null;
374
+ }
375
+ // Какие служебные файлы трогает работа: право просим только на них (проверяющий, 15.10.2026: просили доступ к
376
+ // robots.txt и карте сайта, а таких работ в списке не было).
377
+ const FILE_NAMES = { viewport: 'template', resources: 'template', 'js-content': 'template', 'agent-markdown': 'template', robots: 'robots.txt', 'robots-block': 'robots.txt', 'robots-ai': 'robots.txt', 'ai-search-access': 'robots.txt', sitemap: 'sitemap', 'sitemap-foreign': 'sitemap', 'sitemap-health': 'sitemap', llms: 'llms.txt', 'llms-health': 'llms.txt' };
378
+ const FILE_LABEL = { ru: { 'robots.txt': 'robots.txt', sitemap: 'карта сайта', 'llms.txt': 'llms.txt', server: 'настройки сервера', template: 'шаблон и код сайта' }, en: { 'robots.txt': 'robots.txt', sitemap: 'the sitemap', 'llms.txt': 'llms.txt', server: 'the server settings', template: 'the site template and code' } };
379
+ /*
380
+ * Что делает Fix сам, по замечаниям проверяющего (круг 3, 15.10.2026):
381
+ * - факты владельца не обещаем сделать «без вас»: какие профили есть, какой мессенджер, тексты политики, контакты и
382
+ * адрес, если их нет на сайте; такие пункты уходят в «нужны ваши факты или решения»;
383
+ * - одна работа одной строкой: H1 на главной и H1 на страницах, разметка организации на главной и с адресом, два пункта
384
+ * про llms.txt; строка называет, сколько находок закрывает, поэтому числа письма сходятся с отчётом;
385
+ * - «формы нет» не пункт про глубину формы: куда слать заявки, решает владелец.
386
+ */
387
+ const MERGE = [['h1', 'h1-sample'], ['schema', 'org-schema', 'trust-entity'], ['llms', 'llms-health']];
388
+ const MERGED_LABEL = { h1: { ru: 'Заголовки H1', en: 'H1 headings' }, schema: { ru: 'Разметка организации', en: 'Organisation markup' }, llms: { ru: 'llms.txt', en: 'llms.txt' } };
389
+ function ownerFacts(c, audit) {
390
+ const v = String(c.value || '');
391
+ const site = audit.sample || [];
392
+ const addr = site.find((p) => p && p.addressText);
393
+ if (c.id === 'trust-entity') return !addr;
394
+ if (c.id === 'org-schema' || c.id === 'schema') return false;
395
+ if (c.id === 'trust-profiles') return !(c.listings && c.listings.length);
396
+ if (c.id === 'conv-messenger') return true;
397
+ if (c.id === 'trust-policies') return true;
398
+ if (c.id === 'trust-contact') return !/phone|email|телефон|почт/i.test(v);
399
+ if (c.id === 'trust-about') return c.status === 'bad';
400
+ // Блока вопросов на сайте нет: размечать нечего, вопросы и ответы даёт владелец (проверяющий, 15.10.2026).
401
+ if (c.id === 'faq-schema') return !site.some((p) => p && p.faqBlock);
402
+ return false;
403
+ }
404
+ export function fixOfferItems(audit, { lang = 'ru' } = {}) {
405
+ const ru = lang === 'ru';
406
+ const plan = buildFixPlan(audit, { platform: 'files', lang, price: fixPrice(lang) });
407
+ const included = (plan.included || []).map((x) => x.check || {});
408
+ const groups = []; const seen = new Set(); let moved = 0;
409
+ for (const c of included) {
410
+ if (seen.has(c.id)) continue;
411
+ if (ownerFacts(c, audit)) { moved += 1; seen.add(c.id); continue; }
412
+ const grp = MERGE.find((g) => g.includes(c.id));
413
+ const members = grp ? included.filter((x) => grp.includes(x.id) && !seen.has(x.id) && !ownerFacts(x, audit)) : [c];
414
+ for (const m of members) seen.add(m.id);
415
+ const acts = members.map((m) => measuredFixAction(m, ru, audit)).filter(Boolean);
416
+ if (!acts.length) { moved += members.length; continue; }
417
+ const label = members.length > 1 ? (MERGED_LABEL[grp[0]] || {})[ru ? 'ru' : 'en'] || String(c.label || c.id).trim() : String(c.label || c.id).trim();
418
+ const word = (n) => (ru ? `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'находка' : [2, 3, 4].includes(n % 10) && ![12, 13, 14].includes(n % 100) ? 'находки' : 'находок'}` : `${n} findings`);
419
+ groups.push({ ids: members.map((m) => m.id), text: `${label}${members.length > 1 ? ` (${word(members.length)})` : ''}: ${[...new Set(acts)].join('; ')}` });
420
+ }
421
+ const ids = groups.flatMap((g) => g.ids);
422
+ const rest = (plan.needsClient || []).length + (plan.notPossible || []).length + moved;
423
+ const fileNames = [...new Set(ids.filter((id) => FILE_WORK.has(id)).map((id) => FILE_NAMES[id] || 'server'))].map((k) => FILE_LABEL[ru ? 'ru' : 'en'][k]);
424
+ return { items: groups.map((g) => g.text), closed: ids.length, total: ids.length + rest, rest, files: fileNames.length > 0, fileNames, editor: ids.some((id) => !FILE_WORK.has(id)) };
425
+ }
@@ -1,3 +1,4 @@
1
+ import { noAnswerNeeded } from './pagekind.mjs';
1
2
  /**
2
3
  * Пакет Foundation: работа с текстами, посчитанная по страницам, а не на глаз.
3
4
  *
@@ -39,7 +40,7 @@ const PAGE_ISSUES = [
39
40
  },
40
41
  {
41
42
  id: 'answer-first',
42
- test: (p) => p.answerFirst === false,
43
+ test: (p) => p.answerFirst === false && !noAnswerNeeded(p),
43
44
  ru: { what: 'нет ответа в первом абзаце', action: 'переписать начало так, чтобы в первых 20 - 90 словах стоял ответ с цифрой' },
44
45
  en: { what: 'no answer in the opening paragraph', action: 'rewrite the opening so the first 20 to 90 words carry an answer with a figure' },
45
46
  },
package/src/i18n.mjs CHANGED
@@ -22,16 +22,21 @@ const plural = (n, one, few, many) => {
22
22
  /** Проценты по-русски склоняются: 31 процент, 32 процента, 35 процентов. */
23
23
  const pct = (n) => plural(Number(n), 'процент', 'процента', 'процентов');
24
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': 'Профи' };
25
+ const PROFILE_NAMES = { 'max.ru': 'MAX', 'web.max.ru': 'MAX', 'wikidata.org': 'Wikidata', 'threads.net': 'Threads', 'threads.com': 'Threads', 'youtu.be': 'YouTube', 'm.youtube.com': 'YouTube', 'wa.me': 'WhatsApp', 'zen.yandex.ru': 'Дзен', 'yandex.ru': 'Яндекс', '2gis.ru': '2ГИС', 'prodoctorov.ru': 'ПроДокторов', 'avito.ru': 'Авито', 'spravker.ru': 'Справкер', 'zoon.ru': 'Zoon', 'crunchbase.com': 'Crunchbase', 'trustpilot.com': 'Trustpilot', 'google.com': 'Google', 'medium.com': 'Medium', 'reddit.com': 'Reddit', 'vc.ru': 'vc.ru', '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
26
  const profileName = (h) => PROFILE_NAMES[String(h).trim().replace(/^www\./, '')] || String(h).trim();
27
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}`;
28
+ // Число и список из одного источника: «указано 6 профилей» при пяти названиях читалось как ошибка (два адреса одной
29
+ // площадки и обрезка списка, прогон на своих сайтах 15.10.2026). Больше восьми: шесть названий и «и ещё N».
30
+ export function profileLine(hosts, lang = 'en') {
31
+ const uniq = [...new Set(hosts.map(profileName))]; const total = uniq.length;
32
+ const shown = total > 8 ? uniq.slice(0, 6) : uniq; const more = total - shown.length;
33
+ if (lang === 'ru') return `${total === 1 ? 'указан' : 'указано'} ${plural(total, 'профиль', 'профиля', 'профилей')}: ${shown.join(', ')}${more ? ` и ещё ${more}` : ''}`;
34
+ return `${total} profile${total === 1 ? '' : 's'}: ${shown.join(', ')}${more ? ` and ${more} more` : ''}`;
32
35
  }
33
36
  const pages = (n) => plural(Number(n), 'страница', 'страницы', 'страниц');
34
- const pagesOf = (a, b) => `${a} из ${pages(b)}`;
37
+ // После «из» родительный падеж: «1 из 19 страниц», «0 из 1 страницы» (прогон на 20 сайтах, 15.10.2026).
38
+ const ofPages = (n) => `${n} ${(Number(n) % 10 === 1 && Number(n) % 100 !== 11) ? 'страницы' : 'страниц'}`;
39
+ const pagesOf = (a, b) => `${a} из ${ofPages(b)}`;
35
40
  /** «из 21 страницы», «из 20 страниц»: после «из» родительный падеж. */
36
41
  // «1 из 20 страниц указывает»: глагол по числу страниц, где это есть (cimes.ru, 14.10.2026).
37
42
  const vb = (n, one, many) => (Number(n) % 10 === 1 && Number(n) % 100 !== 11 ? one : many);
@@ -48,7 +53,8 @@ export function localiseBasisNote(note) {
48
53
  }
49
54
  const [, ok, total, warn, bad] = m;
50
55
  const parts = [`${ok} из ${plural(Number(total), 'проверки', 'проверок', 'проверок')} пройдено`];
51
- if (warn) parts.push(`${Number(warn)} частично`);
56
+ // «частично» рядом с «Нет» в таблице читалось как противоречие: такая проверка даёт половину балла, так и пишем (15.10.2026).
57
+ if (warn) parts.push(`${plural(Number(warn), 'засчитана', 'засчитаны', 'засчитаны')} наполовину`);
52
58
  if (bad) parts.push(plural(Number(bad), 'провалена', 'провалены', 'провалено'));
53
59
  return parts.join(', ');
54
60
  }
@@ -69,7 +75,7 @@ const RU = {
69
75
  ttfb: { label: 'Время до первого байта', value: (v) => `${nums(v)[0]} мс`, comment: () => 'один запрос от проверяющего, а не лабораторный тест' },
70
76
  robots: {
71
77
  label: 'robots.txt',
72
- value: (v) => { const n = nums(v); if (/HTTP/.test(v)) return `не отдаётся, код ${n[0]}`; const map = /no sitemap line/i.test(v) ? 'строки с картой сайта нет' : `${plural(Number(n[2]), 'строка', 'строки', 'строк')} с картой сайта`; return `${plural(Number(n[0]), 'запрещающее правило', 'запрещающих правила', 'запрещающих правил')} в ${plural(Number(n[1]), 'блоке', 'блоках', 'блоках')} агентов, ${map}`; },
78
+ value: (v) => { const n = nums(v); if (/HTTP/.test(v)) return `не отдаётся, код ${n[0]}`; const blocks = /agent block/i.test(v); const map = /no sitemap line/i.test(v) ? 'строки с картой сайта нет' : `${plural(Number(n[blocks ? 2 : 1]), 'строка', 'строки', 'строк')} с картой сайта`; return `${plural(Number(n[0]), 'запрещающее правило', 'запрещающих правила', 'запрещающих правил')}${blocks ? ` в ${plural(Number(n[1]), 'блоке', 'блоках', 'блоках')} агентов` : ''}, ${map}`; },
73
79
  comment: (c) => (/no Sitemap/.test(c) ? 'нет строки Sitemap' : ''),
74
80
  },
75
81
  'robots-block': { label: 'robots.txt закрывает весь сайт', value: () => 'Disallow: / под User-agent: *', comment: () => 'поисковым системам сказано не обходить ничего' },
@@ -96,13 +102,14 @@ const RU = {
96
102
  },
97
103
  },
98
104
  'sitemap-foreign': { label: 'В карте сайта чужие хосты', value: (v) => `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')} на других хостах` },
99
- 'sitemap-health': { label: 'Адреса из карты сайта отвечают 200', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} адресов выборки из карты сайта отвечают 200${/redirect/.test(v) ? `, ${n[2]} перенаправляют` : ''}${/broken/.test(v) ? ', есть битые' : ''}`; } },
105
+ 'sitemap-health': { label: 'Ответы адресов из карты сайта', value: (v) => { const n = nums(v); const red = Number((v.match(/(\d+) redirect/) || [])[1] || 0); const brk = Number((v.match(/(\d+) broken/) || [])[1] || 0); // «200 перенаправляют» брал третье число строки, а это код 200 (perina.by, 15.10.2026)
106
+ return `${n[0]} из ${n[1]} ${Number(n[1]) % 10 === 1 && Number(n[1]) % 100 !== 11 ? 'адреса' : 'адресов'} выборки из карты сайта ${Number(n[0]) % 10 === 1 && Number(n[0]) % 100 !== 11 ? 'отвечает' : 'отвечают'} 200${red ? `, ${red} ${red % 10 === 1 && red % 100 !== 11 ? 'перенаправляет' : 'перенаправляют'}` : ''}${brk ? `, ${brk} ${brk % 10 === 1 && brk % 100 !== 11 ? 'не открывается' : 'не открываются'}` : ''}`; } },
100
107
  llms: {
101
108
  label: 'llms.txt',
102
109
  value: (v) => (/not present/.test(v) ? 'нет'
103
110
  : /HTML page/.test(v) ? 'отдаёт HTML-страницу вместо текстового индекса'
104
111
  : `${plural(Number(nums(v)[0]), 'ссылка', 'ссылки', 'ссылок')}, ${nums(v)[1]} на другие хосты`),
105
- comment: (c) => (/no short site map for AI agents|nothing to read/.test(c) ? 'короткой карты сайта для ИИ-агентов нет, им приходится разбирать страницы самим; собрать её это работа на день' : c ? 'система ИИ может принять сайт за чужой' : ''),
112
+ comment: (c) => (/no short site map for AI agents|nothing to read/.test(c) ? 'короткой карты сайта для ИИ-агентов нет, им приходится разбирать страницы самим; собрать её можно за день' : c ? 'система ИИ может принять сайт за чужой' : ''),
106
113
  },
107
114
  // Где ноль означает отсутствие элемента, по-русски это тоже пишется словами, а не цифрой:
108
115
  // «0 знаков» в задании для исполнителя читается как обрывок.
@@ -119,7 +126,7 @@ const RU = {
119
126
  'og-image': { label: 'og:image на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (c ? 'без og:image мессенджеры и соцсети показывают ссылку без картинки' : '') },
120
127
  'twitter-card': { label: 'Карточка Twitter', value: (v) => (/no twitter/.test(v) ? 'не задана' : v.replace('twitter:card', 'тип')), comment: (c) => (c ? 'на неё опираются X и часть читалок, когда Open Graph неполный' : '') },
121
128
  '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 ? 'ставить только там, где страница правда ведёт по шагам: разметить ею что-то другое значит соврать системе' : '') },
129
+ 'howto-schema': { label: 'Разметка пошаговых инструкций', value: (v) => (/no step-by-step/.test(v) ? 'пошаговых страниц в выборке нет' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`), comment: (c) => (c ? 'ставить только там, где страница правда ведёт по шагам: если разметить ею что-то другое, система получит неправду' : '') },
123
130
  'product-schema': { label: 'Разметка товара', value: (v) => (/no product/.test(v) ? 'страниц товара в выборке нет' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`), comment: (c) => (c ? 'только для страниц, которые правда продают названную вещь с ценой' : '') },
124
131
  'redirect-chain': { label: 'Переадресации до главной', value: (v) => (/without a redirect/.test(v) ? 'адрес отвечает сразу, без переадресаций' : `${plural(Number(nums(v)[0]), 'переадресация', 'переадресации', 'переадресаций')} до ответа`), comment: (c) => (c ? 'направьте первый адрес сразу на конечный: каждый прыжок стоит времени и веса ссылки' : '') },
125
132
  favicon: { label: 'Значок сайта', value: (v) => (/declared/.test(v) ? 'объявлен в шапке страницы' : /answers 200/.test(v) ? '/favicon.ico отвечает' : 'не объявлен, и /favicon.ico не отвечает'), comment: (c) => (c ? 'вкладка, закладка и карточка при пересылке ссылки останутся с пустым квадратом' : '') },
@@ -138,19 +145,19 @@ const RU = {
138
145
  hreflang: { label: 'hreflang', value: (v) => (/none/.test(v) ? 'нет, сайт одноязычный' : `${plural(Number(nums(v)[0]), 'альтернатива', 'альтернативы', 'альтернатив')}`) },
139
146
  cms: { label: 'Система управления сайтом (CMS)', value: (v) => v.replace(' (detected from the page code)', ' (определён по коду страницы)').replace(' (from the X-Powered-By server header)', ' (по заголовку сервера X-Powered-By)') },
140
147
  xmlrpc: { label: 'xmlrpc.php', value: (v) => `отвечает ${nums(v)[0]}`, comment: () => 'открытая точка для атак, пользы для поиска никакой' },
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 ? 'странице' : 'страницах'} выборки`; } },
148
+ alt: { label: 'Подписи к картинкам', value: (v) => { const n = nums(v); const heavy = v.match(/; (\d+) of the (\d+) without alt are on (\S+)$/); const all = v.match(/; all (\d+) without alt are on (\S+)$/); return `${n[0]} из ${n[1]} картинок имеют подпись, это ${pct(n[2])}, на ${n[3]} ${Number(n[3]) % 10 === 1 && Number(n[3]) % 100 !== 11 ? 'странице' : 'страницах'} выборки${heavy ? `; из ${heavy[2]} ${Number(heavy[2]) % 10 === 1 && Number(heavy[2]) % 100 !== 11 ? 'картинки' : 'картинок'} без подписи ${heavy[1]} на странице ${heavy[3]}` : all ? `; все ${all[1]} без подписи на странице ${all[2]}` : ''}`; } },
142
149
  '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('; ')) },
143
150
  'dup-titles': { label: 'Повторы заголовков', value: (v) => (/its own title/.test(v) ? 'у каждой проверенной страницы свой заголовок' : `${plural(Number(nums(v)[0]), 'заголовок повторяется', 'заголовка повторяются', 'заголовков повторяются')} на разных страницах`) },
144
- thin: { label: 'Объём текста', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки короче 300 слов`) },
151
+ thin: { label: 'Объём текста', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки короче 300 слов${/list pages not counted: (.+)$/.test(v) ? `, страницы-списки не считаются: ${v.match(/list pages not counted: (.+)$/)[1]}` : ''}`) },
145
152
  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(', ')}: в индексе не нужны и засоряют его`; } },
146
153
  'answer-first': {
147
154
  label: 'Ответ в первом абзаце',
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]} страницах выборки`; },
155
+ value: (v) => { const n = nums(v); const miss = Number(n[1]) - Number(n[0]); const skip = (v.match(/not counted: ([^)]+)\)/) || [])[1]; const tail = skip ? `; юридические страницы и страницы-списки не считаются: ${skip}` : ''; return (miss > 0 ? `прямого ответа с цифрой в первом абзаце (20-90 слов сразу после H1) нет на ${miss} из ${n[1]} ${pagesGen(n[1])} выборки` : `прямой ответ с цифрой в первом абзаце есть на всех ${n[1]} ${Number(n[1]) % 10 === 1 && Number(n[1]) % 100 !== 11 ? 'странице' : 'страницах'} выборки`) + tail; },
149
156
  comment: (c) => (c ? 'именно этот абзац ответная система забирает в цитату' : ''),
150
157
  },
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 ? 'системы цитируют разделы, а не сплошной текст' : '') },
152
- tables: { label: 'Таблицы в тексте', value: (v) => (/no table/.test(v) ? 'на проверенных страницах таблиц нет' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки используют таблицу`), comment: (c) => (c ? 'таблица это второй по частоте формат цитирования после первого абзаца' : '') },
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 ? 'цифра без источника это первое, что система выбрасывает' : '') },
158
+ sections: { label: 'Разбивка на разделы', value: (v) => { if (/no long text page/.test(v)) return 'длинных текстовых страниц, которые надо делить на разделы, в выборке нет (юридические, списки и страницы короче 600 слов не считаются)'; const n = nums(v); const few = Number(n[1]) - Number(n[0]); const tail = /600 words/.test(v) ? ' (страницы от 600 слов, без юридических и списков)' : ''; const one = Number(n[1]) % 10 === 1 && Number(n[1]) % 100 !== 11; return (few > 0 ? `меньше трёх подзаголовков H2 на ${few} из ${n[1]} ${one ? 'длинной текстовой страницы' : 'длинных текстовых страниц'} выборки` : Number(n[1]) === 1 ? 'три и более подзаголовка H2 на единственной длинной текстовой странице выборки' : `три и более подзаголовка H2 на всех ${n[1]} ${one ? 'длинной текстовой странице' : 'длинных текстовых страницах'} выборки`) + tail; }, comment: (c) => (c ? 'системы цитируют разделы, а не сплошной текст' : '') },
159
+ tables: { label: 'Таблицы в тексте', value: (v) => (/no table/.test(v) ? 'на проверенных страницах таблиц нет' : `таблица есть на ${nums(v)[0]} из ${ofPages(nums(v)[1])} выборки`), comment: (c) => (/not on every page/.test(c) ? 'таблица нужна не на каждой странице, а там, где есть что сравнить' : c ? 'после первого абзаца таблицы цитируют чаще всего' : '') },
160
+ sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); if (n.length < 2) return /no sampled page carries a figure/.test(v) ? 'на страницах выборки нет цифр, которым нужен источник' : 'не измеряли'; return `источник цифры назван словами или ссылкой рядом с ней на ${n[0]} из ${ofPages(n[1])} выборки, где есть цифры статистики (доли, количества, сравнения)`; }, comment: (c) => (c ? 'цифра без источника это первое, что система выбрасывает' : '') },
154
161
  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], 'показывает', 'показывают')} дату`) },
155
162
  '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 ? 'ИИ читает этот файл вместо обхода сайта: каждая мёртвая или закрытая запись это страница, которую он процитирует неверно или не процитирует вовсе' : '') },
156
163
  'agent-card': { label: 'Карточка агента (/.well-known/agent.json)', value: (v) => (/valid JSON/.test(v) ? 'есть, и это правильный JSON' : /not JSON/.test(v) ? 'есть, но это не JSON' : 'нет'), comment: (c) => (c ? 'ранний и необязательный стандарт: файл описывает программного ИИ-агента, если он у компании есть; без своего агента он не нужен' : '') },
@@ -164,14 +171,14 @@ const RU = {
164
171
  : v.replace(/\bphone\b/g, 'телефон').replace(/\bemail\b/g, 'почта').replace(/a messenger/g, 'мессенджер').replace(/, | and /g, ', ').replace(/, ([^,]+)$/, ' и $1')),
165
172
  },
166
173
  '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 ? 'ссылки на настоящие профили компании в разметке помогают поиску и ИИ убедиться, что компания существует, и это ничего не стоит' : '') },
167
- 'trust-about': { label: 'Страница о компании', value: (v) => (/no about/.test(v) ? 'страницы о компании нет или на неё не ведут ссылки' : 'на неё ведут ссылки с проверенных страниц') },
174
+ 'trust-about': { label: 'Страница о компании', value: (v) => (/no about/.test(v) ? 'страницы о компании нет или на неё не ведут ссылки' : `есть${/: (\S+)$/.test(v) ? `: ${v.match(/: (\S+)$/)[1]}` : ''}, на неё ведут ссылки с проверенных страниц`) },
168
175
  '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, 'согласие на обработку данных')}`) },
169
176
  'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'даёт', 'дают')} форму, контактную ссылку или страницу контактов`; } },
170
177
  // С 0.37.0 «на первом экране» без процента (первый экран не меньше 900 знаков текста); старые audit.json несут процент.
171
- 'conv-cta': { label: 'Призыв к действию на первом экране', value: (v) => { const n = nums(v); return n[2] ? `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'ставит', 'ставят')} его в первых ${n[2]} процентах страницы` : `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'ставит', 'ставят')} его на первый экран`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
172
- '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 ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
178
+ 'conv-cta': { label: 'Призыв к действию на первом экране', value: (v) => { const n = nums(v); return n[2] ? `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'ставит', 'ставят')} призыв к действию в первых ${n[2]} процентах страницы` : `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'ставит', 'ставят')} призыв к действию на первый экран`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
179
+ '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 ? 'страницы' : 'страниц'} выборки; ${/of them/.test(v) ? `на ${nums(v)[2]} из них кнопка видна на первом экране` : `кнопка на первом экране на ${nums(v)[2]} из ${nums(v)[1]}`}` : /messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${ofPages(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 ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
173
180
  'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => (/only (enquiry )?form/.test(v) ? `в единственной форме заявки в выборке ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')}` : `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`), comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
174
- 'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
181
+ 'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${ofPages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
175
182
  };
176
183
 
177
184
  /** Проверки, для которых перевода нет. Пусто означает, что покрыто всё. */
@@ -183,8 +190,21 @@ export function untranslated(checks) {
183
190
  * Возвращает копию списка проверок с русскими названиями и текстами.
184
191
  * Проверка без записи возвращается как есть: молча английский текст не прячем.
185
192
  */
193
+ const TECH_WORDS = /\b(?:HTML|URL|https?|www|llms\.txt|robots\.txt|sitemap(?:\.xml)?|JSON-LD|Schema\.org|og:\w+|CMS|PDF|API|SEO|AEO|GEO|FAQ(?:Page)?|TTFB|ALT|ChatGPT|Perplexity|Claude|Gemini|Copilot)\b/gi;
194
+ /**
195
+ * Английский отчёт без шаблонных «(s)»: «20 sampled page(s)» становится «20 sampled pages», «1 H1 tag(s)» «1 H1 tag»
196
+ * (проверяющий, moregroup.estate, 15.10.2026). Число берётся ближайшее перед словом; «status(es)» тоже.
197
+ */
198
+ export function plainPlural(text) {
199
+ // Цифра внутри слова («H1») не число: «2 H1 tag(s)» становилось «2 H1 tag». «1 of 20 pages carry» с глаголом по первому числу (16.10.2026).
200
+ return String(text ?? '').replace(/([a-z]+)\((e?s)\)/gi, (m, w, suf, off, all) => {
201
+ const nums = all.slice(Math.max(0, off - 60), off).match(/(?<![A-Za-z])\d+(?:[.,]\d+)?/g);
202
+ const n = nums ? Number(nums[nums.length - 1].replace(',', '.')) : 2;
203
+ return n === 1 ? w : `${w}${suf}`;
204
+ }).replace(/(?<![\d.,])1 of (\d+) ([a-z ]*?pages?) (carry|open|use|name|state|put|offer|give|have)\b/g, (m, b, p, v) => `1 of ${b} ${p} ${v === 'carry' ? 'carries' : v === 'have' ? 'has' : `${v}s`}`);
205
+ }
186
206
  export function localiseChecks(checks, lang = 'ru') {
187
- if (lang !== 'ru') return checks;
207
+ if (lang !== 'ru') return (checks || []).map((c) => (/\((?:e?s)\)/.test(`${c.value ?? ''}${c.comment ?? ''}`) ? { ...c, value: c.value == null ? c.value : plainPlural(c.value), comment: c.comment == null ? c.comment : plainPlural(c.comment) } : c));
188
208
  return (checks || []).map((c) => {
189
209
  const r = RU[c.id];
190
210
  if (!r) return c;
@@ -192,11 +212,15 @@ export function localiseChecks(checks, lang = 'ru') {
192
212
  // Уже русский текст (его пишет второй взгляд по сайту) второй раз не переводим: перевод ждёт английский шаблон и испортил бы строку.
193
213
  // Строки, которые уже написаны по-русски (второй взгляд по сайту ставит флаг localised), второй раз не переводим.
194
214
  // Уже русская строка (без английских слов вне кавычек) второй раз не переводится: перевод ждёт английский шаблон.
195
- const ruAlready = (x) => { const t = String(x || '').replace(/"[^"]*"|«[^»]*»/g, ''); return Boolean(c.localised) || (/[А-Яа-яЁё]/.test(t) && !/[A-Za-z]{3,}/.test(t)); };
215
+ // Технические слова (HTML, URL, llms.txt) не делают русскую строку английской: «отдаёт HTML-страницу» второй раз шла
216
+ // в английский шаблон с числами и становилась «NaN ссылок, undefined на другие хосты» (plitex-s.ru, 15.10.2026).
217
+ const ruAlready = (x) => { const t = String(x || '').replace(/"[^"]*"|«[^»]*»/g, '').replace(TECH_WORDS, ''); return Boolean(c.localised) || (/[А-Яа-яЁё]/.test(t) && !/[A-Za-z]{3,}/.test(t)); };
218
+ // Перевод, который родил «NaN» или «undefined» (шаблон не узнал строку), не ставим: остаётся исходная.
219
+ const safe = (from, to) => (/\b(?:NaN|undefined)\b/.test(to) && !/\b(?:NaN|undefined)\b/.test(String(from)) ? from : to);
196
220
  // Правка проверки (applyFix) уже написана на языке отчёта: её не переводим, иначе «www» в русском тексте
197
221
  // отправлял строку в английский шаблон и правка пропадала (cimes.ru, 14.10.2026, три круга без схождения).
198
- try { if (r.value && !c.fixedValue && !ruAlready(c.value)) out.value = r.value(c.value ?? ''); } catch { /* оставляем исходный текст */ }
199
- try { if (r.comment && !c.fixedComment && !ruAlready(c.comment)) out.comment = r.comment(c.comment ?? ''); } catch { /* оставляем исходный текст */ }
222
+ try { if (r.value && !c.fixedValue && !ruAlready(c.value)) out.value = safe(c.value, r.value(c.value ?? '')); } catch { /* оставляем исходный текст */ }
223
+ try { if (r.comment && !c.fixedComment && !ruAlready(c.comment)) out.comment = safe(c.comment, r.comment(c.comment ?? '')); } catch { /* оставляем исходный текст */ }
200
224
  return out;
201
225
  });
202
226
  }
package/src/index.mjs CHANGED
@@ -3,7 +3,7 @@ export { collect, computeScores, computeOverall, verifyScores, SCORE_AREAS } fro
3
3
  export { checkVisibility, normaliseInput, parseRobots, agentVerdict, MESSAGES as VISIBILITY_MESSAGES, VISIBILITY_DEFAULTS } from './visibility.mjs';
4
4
  export { render, checkNarrative, overallSummary } from './render.mjs';
5
5
  export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18n.mjs';
6
- export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
6
+ export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS, fixOfferItems, FIX_PRICE, fixPrice } from './fix.mjs';
7
7
  export { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from './foundation.mjs';
8
8
  export { draftNarrative, stillEmpty } from './narrative.mjs';
9
9
  export { agentPrompt, agentPrompts, renderAgentPrompts, firstFixParts, FIRST_FIX_IDS } from './prompts.mjs';