@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/prompts.mjs CHANGED
@@ -14,7 +14,8 @@
14
14
  * Заданий с собственным текстом столько, сколько проверок мы умеем чинить осмысленно. Для
15
15
  * остальных собирается общее задание из описания работы: оно короче, но не врёт.
16
16
  */
17
- import { isListPage } from './pagekind.mjs';
17
+ import { isListPage, isLegalPage, isContactPage } from './pagekind.mjs';
18
+ import { toVy } from './vy.mjs';
18
19
  import { FIX_ACTIONS } from './fix.mjs';
19
20
  import { localiseChecks } from './i18n.mjs';
20
21
  import { vocabFor } from './niche.mjs';
@@ -147,7 +148,7 @@ const P = {
147
148
  en: { task: 'Remove every URL that points at another domain from the sitemap: sitemap.xml should hold only this site\'s pages on its main address. If a plugin or the build generates the sitemap, fix its setting rather than the file by hand, or the URLs come back on the next build.', verify: 'Every URL in sitemap.xml starts with the site\'s main address.' },
148
149
  },
149
150
  'sitemap-health': {
150
- ru: { task: 'Пройди по адресам из карты сайта и оставь в ней только те, что отвечают кодом 200. Адреса с переадресацией замени на конечные, мёртвые (404, 410, 5xx) убери. Если мёртвая страница должна существовать, не создавай её сама, а сообщи владельцу списком.', verify: 'Каждый адрес из sitemap.xml отвечает кодом 200 без переадресации.' },
151
+ ru: { task: 'Пройди по адресам из карты сайта и оставь в ней только те, что отвечают кодом 200. Адреса с переадресацией замени на конечные, мёртвые (404, 410, 5xx) убери. Если мёртвая страница должна существовать, не создавай её от себя, а сообщи владельцу списком.', verify: 'Каждый адрес из sitemap.xml отвечает кодом 200 без переадресации.' },
151
152
  en: { task: 'Go through the sitemap URLs and keep only those that answer 200. Replace redirecting URLs with their final address and drop dead ones (404, 410, 5xx). If a dead page ought to exist, do not invent it: list it for the owner.', verify: 'Every URL in sitemap.xml answers 200 without a redirect.' },
152
153
  },
153
154
  'api-catalog': {
@@ -271,8 +272,27 @@ const withLang = (v, lang) => v[lang === 'ru' ? 'ru' : 'en'];
271
272
  * ctx: { check, audit, lang, ru, v (слова ниши), platform }
272
273
  */
273
274
  const sampleOf = (audit) => (audit?.sample || []).filter((p) => p && p.title !== undefined);
274
- const pathOf = (u) => { try { return new URL(u).pathname; } catch { return String(u || ''); } };
275
+ // Кириллический адрес буквами: «%D0%B2…» исполнитель не прочтёт (gruzotaksi.by, 15.10.2026).
276
+ const pathOf = (u) => { try { const p = new URL(u).pathname; try { return decodeURI(p); } catch { return p; } } catch { return String(u || ''); } };
277
+ const HOSTED = new Set(['tilda', 'wix', 'squarespace', 'shopify']);
278
+ const noFormsOn = (audit) => /no form|формы нет/i.test(String((audit?.checks || []).find((c) => c.id === 'conv-form-depth')?.value || ''));
279
+ const PF_FAQ_NEW = (ctx) => PF['faq-schema-old'](ctx);
275
280
  const PF = {
281
+ // «Адреса в пояснении», а пояснения с адресами в файле задач нет (adelaidesculpsure.com.au, loomenio.com, norkisandco.com, 15.10.2026):
282
+ // перечисляем все страницы прямо в задании.
283
+ 'schema-all': ({ audit, ru }) => { const miss = sampleOf(audit).filter((p) => !(p.schemaTypes || []).length).map((p) => pathOf(p.url)); if (!miss.length) return null; const b = withLang(P['schema-all'], ru ? 'ru' : 'en'); return { ...b, task: b.task.replace('(адреса в пояснении)', `(${miss.length}: ${miss.join(', ')})`).replace('(paths in the note)', `(${miss.length}: ${miss.join(', ')})`) }; },
284
+ 'title-all': ({ audit, ru }) => { const miss = sampleOf(audit).filter((p) => !p.titleLength && !p.title).map((p) => pathOf(p.url)); if (!miss.length) return null; const b = withLang(P['title-all'], ru ? 'ru' : 'en'); return { ...b, task: b.task.replace('(их адреса в пояснении к проверке)', `(${miss.join(', ')})`).replace('(their paths are in the note to this check)', `(${miss.join(', ')})`) }; },
285
+ // Файлы главной: на конструкторе своих файлов и сборки нет, а «что нужно для форм» при сайте без форм читалось как
286
+ // ошибка (babynanny.ru: «На Сайте отсутствуют формы», 15.10.2026).
287
+ resources: ({ audit, ru, platform }) => {
288
+ const keep = noFormsOn(audit) ? (ru ? 'оплаты, кнопок мессенджеров и счётчиков' : 'payments, messenger buttons and counters') : (ru ? 'форм, оплаты и счётчиков' : 'forms, payments and counters');
289
+ if (HOSTED.has(platform)) return ru
290
+ ? { task: `Сократи число скриптов на главной в настройках ${PLATFORM_LABEL[platform]}: отключи виджеты и вставки чужого кода, которые никто не использует (старые счётчики, чаты, пиксели), и проверь блоки с внешним кодом. Файлы самой платформы не трогай: их собирает ${PLATFORM_LABEL[platform]}. То, что нужно для ${keep}, оставь и перечисли владельцу, что отключено.`, verify: `Главная грузит меньше скриптов, чем при замере, а ${noFormsOn(audit) ? 'кнопки мессенджеров' : 'формы'}, меню и счётчики продолжают работать.` }
291
+ : { task: `Cut the scripts the home page loads in the ${PLATFORM_LABEL[platform]} settings: switch off widgets and embedded third-party code nobody uses (old counters, chats, pixels) and check blocks with external code. Leave the platform's own files alone: ${PLATFORM_LABEL[platform]} builds them. Keep whatever ${keep} need, and list for the owner what you switched off.`, verify: `The home page loads fewer scripts than when we measured, and ${noFormsOn(audit) ? 'messenger buttons' : 'forms'}, menus and counters still work.` };
292
+ return ru
293
+ ? { task: `Сократи число скриптов, которые грузит главная: убери подключения неиспользуемых плагинов и виджетов, объедини мелкие файлы при сборке, несрочные скрипты подключи с defer или после загрузки. Ничего не удаляй вслепую: что нужно для ${keep}, оставь и перечисли владельцу, что убрано.`, verify: `Главная грузит не больше 15 файлов скриптов, а ${noFormsOn(audit) ? 'кнопки мессенджеров' : 'формы'}, меню и счётчики продолжают работать.` }
294
+ : { task: `Cut the number of scripts the home page loads: drop plugins and widgets nobody uses, bundle small files at build, load non-urgent scripts with defer or after load. Remove nothing blindly: keep whatever ${keep} need, and list for the owner what you removed.`, verify: `The home page loads at most 15 script files, and ${noFormsOn(audit) ? 'messenger buttons' : 'forms'}, menus and counters still work.` };
295
+ },
276
296
  title: ({ ru, v }) => (ru
277
297
  ? { task: 'Перепиши заголовок главной (тег title): до 60 знаков, словами, которыми ищут ' + v.people + ', что это за компания и чем она занимается, название компании в конце. Не повторяй слова и не пиши список ключевых слов через запятую.', verify: 'Заголовок главной от 30 до 60 знаков, в нём названо, чем занимается компания, и он отличается от заголовков других страниц.' }
278
298
  : { task: 'Rewrite the home page title: up to 60 characters, in the words ' + v.people + ' search with, saying what the business is and does, company name at the end. No repeated words, no keyword lists.', verify: 'The home page title is 30 to 60 characters, says what the business does, and differs from other page titles.' }),
@@ -281,50 +301,97 @@ const PF = {
281
301
  const sm = sampleOf(audit); const dated = sm.filter((p) => p.dates && (p.dates.published || p.dates.modified));
282
302
  const c = (audit?.checks || []).find((x) => x.id === 'dates');
283
303
  if (c && !/precede/.test(String(c.value)) && dated.length < sm.length / 2) {
284
- const list = dated.slice(0, 5).map((p) => pathOf(p.url)).join(', ');
304
+ const list = dated.map((p) => pathOf(p.url)).join(', ');
285
305
  return ru
286
306
  ? { task: `Дата публикации в разметке (datePublished) есть только на ${dated.length} из ${sm.length} страниц выборки${list ? `: ${list}` : ''}. Добавь её и видимую строку «Опубликовано: число» на страницы, у которых правда есть дата: статьи блога, новости, кейсы портфолио. Страницам услуг и контактов дата не нужна, не ставь её туда.`, verify: 'На статьях, новостях и кейсах в разметке есть datePublished, и та же дата видна на самой странице.' }
287
307
  : { task: `A published date in the markup (datePublished) is on only ${dated.length} of ${sm.length} sampled pages${list ? `: ${list}` : ''}. Add it, with a visible "Published: date" line, to pages that really have a date: blog posts, news, portfolio cases. Service and contact pages need none.`, verify: 'Posts, news and cases carry datePublished in the markup, and the same date is visible on the page.' };
288
308
  }
289
- return datesGeneric({ ru });
309
+ return datesGeneric({ ru, audit, platform: detectPlatform(audit) });
290
310
  },
291
311
 
292
- 'trust-entity': ({ ru }) => (ru
293
- ? { task: 'После того как появится разметка организации (задача про неё выше), допиши в неё адрес: город и страну (PostalAddress с addressLocality и addressCountry). Адрес головного офиса спроси у владельца, если на сайте его нет; адреса филиалов бери со страниц сайта.', verify: 'В разметке организации есть город и страна, и они совпадают с адресом, который подтвердил владелец.' }
294
- : { task: 'Once the organisation markup exists (task above), add the address: city and country (PostalAddress with addressLocality and addressCountry). Ask the owner for the head office address if the site does not show it.', verify: 'The organisation markup carries city and country, matching the address the owner confirmed.' }),
312
+ // По значению строки (прогон на своих сайтах, 15.10.2026): «только город и страна» значит дописать улицу и индекс, а
313
+ // не «город и страну», которые уже есть; на задачу про разметку организации ссылаемся, только если она в файле есть.
314
+ // Файл есть, но ссылается на чужие сайты: задача про ссылки, а не «создай llms.txt» (moregroup.estate, 15.10.2026).
315
+ llms: ({ check, ru }) => {
316
+ const n = Number((String(check.value || '').match(/(\d+) to other hosts|(\d+) на другие|(\d+) на чужие/) || []).slice(1).find(Boolean) || 0);
317
+ if (!n) return null;
318
+ const hosts = (String(check.comment || '').match(/links to ([^:]+):/) || [])[1] || '';
319
+ return ru
320
+ ? { task: `В llms.txt ${n} ${n % 10 === 1 && n % 100 !== 11 ? 'ссылка' : [2, 3, 4].includes(n % 10) && ![12, 13, 14].includes(n % 100) ? 'ссылки' : 'ссылок'} на чужие сайты${hosts ? ` (${hosts})` : ''}. Оставь только те, что прямо про компанию (её профили на площадках, отзывы о ней), остальные убери: по ним ИИ может принять компанию за другую.`, verify: 'В llms.txt ссылки ведут на страницы сайта и на профили самой компании, чужих сайтов, не связанных с ней, нет.' }
321
+ : { task: `llms.txt carries ${n} link${n === 1 ? '' : 's'} to other sites${hosts ? ` (${hosts})` : ''}. Keep only those that are about the business itself (its profiles, reviews of it) and remove the rest: an AI system may mistake the business for another one because of them.`, verify: 'llms.txt links only to the site\'s own pages and to the business\'s own profiles, with no unrelated sites.' };
322
+ },
323
+ 'trust-entity': ({ audit, ru }) => {
324
+ const ent = (audit?.checks || []).find((c) => c.id === 'trust-entity');
325
+ const cityOnly = /only the city and country|только город и страна|без улицы/i.test(String(ent?.value || ''));
326
+ const orgTask = (audit?.checks || []).some((c) => c.id === 'org-schema' && (c.status === 'bad' || c.status === 'warn'));
327
+ const pg = sampleOf(audit).find((p) => p.addressText);
328
+ if (cityOnly && pg) return ru
329
+ ? { task: `В разметке организации (PostalAddress) есть город и страна, но нет улицы и индекса. Адрес уже написан на сайте, на странице ${pathOf(pg.url)}: «${pg.addressText}». Перенесите улицу с номером дома и индекс в streetAddress и postalCode; если адрес изменился, уточните у владельца.`, verify: 'В разметке организации есть streetAddress и postalCode, и они совпадают с адресом на сайте.' }
330
+ : { task: `The organisation markup (PostalAddress) has the city and country but no street or postcode. The site already shows the address on ${pathOf(pg.url)}: "${pg.addressText}". Copy the street with the building number and the postcode into streetAddress and postalCode; if the address has changed, check with the owner.`, verify: 'The organisation markup has streetAddress and postalCode, matching the address on the site.' };
331
+ if (cityOnly) return ru
332
+ ? { task: 'В разметке организации (PostalAddress) уже есть город и страна, не хватает улицы с номером дома и индекса: допиши streetAddress и postalCode. Если улица и индекс есть на странице контактов, возьми их оттуда; если нет, спроси у владельца. Если компания принимает клиентов не в офисе и адрес публиковать не хочет, оставь как есть и скажи об этом владельцу.', verify: 'В разметке организации есть streetAddress и postalCode, и они совпадают с адресом на странице контактов или с тем, что подтвердил владелец.' }
333
+ : { task: 'The organisation markup (PostalAddress) already has the city and country; it lacks the street with the building number and the postcode: add streetAddress and postalCode. If the contact page shows the street and postcode, take them from there; if not, ask the owner. If the business does not receive clients at an office and does not want to publish one, leave it and tell the owner.', verify: 'The organisation markup carries streetAddress and postalCode, matching the contact page or what the owner confirmed.' };
334
+ return ru
335
+ ? { task: `${orgTask ? 'Когда появится разметка организации (отдельная задача в этом файле), допиши' : 'Допиши в разметку организации'} адрес: улицу с номером дома, индекс, город и страну (PostalAddress: streetAddress, postalCode, addressLocality, addressCountry). ${pg ? `Адрес уже написан на сайте, на странице ${pathOf(pg.url)}: «${pg.addressText}», возьми его оттуда.` : 'Если у компании есть адрес, где принимают клиентов, спроси его у владельца; если такого адреса нет, оставь разметку без адреса и скажи владельцу, что пункт закрыт.'}`, verify: 'В разметке организации есть улица, индекс, город и страна, и они совпадают с адресом на сайте или с тем, что подтвердил владелец; либо владелец подтвердил, что адреса для клиентов нет.' }
336
+ : { task: `${orgTask ? 'Once the organisation markup exists (a separate task in this file), add' : 'Add to the organisation markup'} the address: street with the building number, postcode, city and country (PostalAddress: streetAddress, postalCode, addressLocality, addressCountry). ${pg ? `The site already shows the address on ${pathOf(pg.url)}: "${pg.addressText}"; take it from there.` : 'If the business has an address where it receives clients, ask the owner for it; if it has none, leave the markup without an address and tell the owner the item is closed.'}`, verify: 'The organisation markup carries street, postcode, city and country, matching the address the owner confirmed.' };
337
+ },
295
338
  'train-bots': ({ ru }) => (ru
296
339
  ? { task: 'Спроси владельца, ставил ли он запрет для этих роботов. Если нет, выясни у хостинга, какое правило сервера или защиты обрывает их соединение, и сними его. Если владелец хочет закрыть обучающих роботов, оставь запрет, но пропиши его явно в robots.txt, чтобы было видно, что это решение.', verify: 'Запрос robots.txt с user-agent каждого названного робота возвращает ответ 200, либо запрет явно записан в robots.txt по решению владельца.' }
297
340
  : { task: 'Ask the owner whether they blocked these robots. If not, ask the host which server or firewall rule drops them and remove it. If the owner wants the training crawlers out, keep the block but also write it into robots.txt so the decision is visible.', verify: 'A robots.txt request with each named robot\'s user-agent returns 200, or the block is written into robots.txt by the owner\'s decision.' }),
298
341
  alt: ({ audit, ru }) => {
299
342
  const s = sampleOf(audit);
300
343
  const missing = s.reduce((a, p) => a + (Number(p.imagesAltMissing) || 0), 0);
301
- const empty = s.reduce((a, p) => a + (Number(p.imagesNoAlt) || 0), 0) - missing;
344
+ const total = s.reduce((a, p) => a + (Number(p.imagesNoAlt) || 0), 0);
345
+ const empty = total - missing;
346
+ // Почти все картинки без подписи на одной странице: задача про неё (moregroup.estate /guides/, 15.10.2026).
347
+ const worst = s.filter((p) => p.imagesNoAlt > 0).sort((x, y) => y.imagesNoAlt - x.imagesNoAlt)[0];
348
+ if (worst && total >= 10 && worst.imagesNoAlt / total > 0.6) { const w = pathOf(worst.url); const rest = total - worst.imagesNoAlt; return ru
349
+ ? { task: `${worst.imagesNoAlt === total ? `Все картинки без подписи (${total})` : `Почти все картинки без подписи (${worst.imagesNoAlt} из ${total})`} стоят на одной странице: ${w}. Впиши непустой alt каждой содержательной картинке на ней (обложки, фото): своими словами, что изображено, от пяти до пятнадцати слов; если это обложки статей, alt можно взять из названия статьи. Значки и фоны оставь с пустым alt="".${rest ? ` На остальных страницах выборки без подписи ещё ${rest}.` : ''}`, verify: `На странице ${w} у каждой содержательной картинки непустой alt.` }
350
+ : { task: `${worst.imagesNoAlt === total ? `All ${total} images without alt` : `Nearly all images without alt (${worst.imagesNoAlt} of ${total})`} sit on one page: ${w}. Give every meaningful image there (covers, photos) a non-empty alt: what it shows, in five to fifteen words; for article covers the article title will do. Icons and backgrounds keep an empty alt="".${rest ? ` The other sampled pages have ${rest} more without alt.` : ''}`, verify: `On ${w} every meaningful image carries a non-empty alt.` }; }
302
351
  if (missing === 0 && empty > 0) return ru
303
- ? { task: 'Атрибут alt стоит у всех картинок, но почти везде пустой. Впиши непустой alt каждой содержательной картинке (фото на страницах, в новостях, галереях и карточках): своими словами, что изображено, от пяти до пятнадцати слов. Значкам оформления темы (иконки, стрелки, фоны) оставь пустой alt="", это правильно. Если непонятно, что на фото, не выдумывай: собери такие файлы в список для владельца.', verify: 'У каждой фотографии на проверенных страницах alt не пустой и описывает изображение; пустой alt="" остался только у значков оформления.' }
304
- : { task: 'Every image already has an alt attribute, but nearly all are empty. Give every meaningful image (photos on pages, posts, galleries, cards) a non-empty alt: what it shows, in five to fifteen words. Theme icons, arrows and backgrounds keep an empty alt="", deliberately. If you cannot tell what a photo shows, do not invent it: list those files for the owner.', verify: 'Every photo on the sampled pages has a non-empty alt describing it; only decorative icons keep alt="".' };
352
+ ? { task: `Атрибут alt стоит у всех картинок, но у ${total} из них он пустой. Впиши непустой alt каждой содержательной картинке (фото на страницах, в новостях, галереях и карточках): своими словами, что изображено, от пяти до пятнадцати слов. Значкам оформления темы (иконки, стрелки, фоны) оставь пустой alt="", это правильно. Если непонятно, что на фото, не выдумывай: собери такие файлы в список для владельца.`, verify: 'У каждой фотографии на проверенных страницах alt не пустой и описывает изображение; пустой alt="" остался только у значков оформления.' }
353
+ : { task: `Every image already has an alt attribute, but ${total} of them are empty. Give every meaningful image (photos on pages, posts, galleries, cards) a non-empty alt: what it shows, in five to fifteen words. Theme icons, arrows and backgrounds keep an empty alt="", deliberately. If you cannot tell what a photo shows, do not invent it: list those files for the owner.`, verify: 'Every photo on the sampled pages has a non-empty alt describing it; only decorative icons keep alt="".' };
305
354
  return null;
306
355
  },
307
356
  h1: ({ audit, ru }) => {
308
357
  const home = sampleOf(audit)[0];
309
358
  if (home && !home.h1Count) return ru
310
- ? { task: 'Добавь на главную один заголовок первого уровня (H1) о том, чем занимается компания. Существующие заголовки H2 не трогай. Не делай H1 из логотипа и из названия сайта в шапке.', verify: 'На главной ровно один H1, и он говорит, чем занимается компания.' }
311
- : { task: 'Add one first-level heading (H1) to the home page saying what the business does. Leave the existing H2s alone. Do not make an H1 out of the logo or the site name in the header.', verify: 'The home page has exactly one H1, and it says what the business does.' };
359
+ ? { task: `Добавь на главную один заголовок первого уровня (H1) о том, чем занимается компания. ${home.h2Count ? 'Существующие заголовки H2 не трогай. ' : ''}Не делай H1 из логотипа и из названия сайта в шапке.`, verify: 'На главной ровно один H1, и он говорит, чем занимается компания.' }
360
+ : { task: `Add one first-level heading (H1) to the home page saying what the business does. ${home.h2Count ? 'Leave the existing H2s alone. ' : ''}Do not make an H1 out of the logo or the site name in the header.`, verify: 'The home page has exactly one H1, and it says what the business does.' };
312
361
  return null;
313
362
  },
314
- 'h1-sample': ({ audit, ru }) => {
363
+ // На конструкторе нет «шаблона темы», текст отдаёт платформа (pilatesvw.ru, refreshdekor.ru на Tilda, 16.10.2026).
364
+ 'js-content': ({ ru, platform }) => (HOSTED.has(platform) ? (ru
365
+ ? { task: `Сайт сделан на ${PLATFORM_LABEL[platform]}: обычный текст страниц платформа отдаёт сразу в HTML. На страницах из строки «Сейчас» найди текст, который вставлен блоком со своим кодом или подгружается скриптом (встроенный HTML, виджет), и перенеси его в обычный текстовый блок редактора.`, verify: 'Текст этих страниц виден в ответе на простой запрос без браузера.' }
366
+ : { task: `The site runs on ${PLATFORM_LABEL[platform]}: the platform serves ordinary page text in the HTML. On the pages under Now, find text placed in a custom code block or loaded by a script (embedded HTML, a widget) and move it into an ordinary text block of the editor.`, verify: 'The text of those pages shows in a plain request without a browser.' }) : null),
367
+ 'h1-sample': ({ audit, ru, platform }) => {
315
368
  const s = sampleOf(audit);
316
369
  const none = s.filter((p) => !p.h1Count).length; const many = s.filter((p) => p.h1Count > 1).length;
370
+ // Страницы без H1 называем адресами: «H1 нет на 2 страницах» исполнителю не говорит где (babynanny.ru, 15.10.2026).
371
+ const noneList = s.filter((p) => !p.h1Count).map((p) => pathOf(p.url)).join(', ');
372
+ const homeMiss = s.length && !s[0].h1Count;
317
373
  if (none && !many) return ru
318
- ? { task: 'На страницах без H1 добавь один заголовок первого уровня с названием страницы (на главной: чем занимается компания). Если H1 не выводит шаблон темы, правь шаблон, а не каждую страницу.', verify: 'На каждой проверенной странице ровно один H1. На внутренних страницах он совпадает с названием страницы, на главной говорит, чем занимается компания.' }
319
- : { task: 'On pages without an H1, add one first-level heading with the page name (on the home page: what the business does). If the theme template does not output an H1, fix the template rather than every page.', verify: 'Every sampled page has exactly one H1: the page name on inner pages, what the business does on the home page.' };
374
+ ? { task: `На страницах без H1 (${noneList}) добавь один заголовок первого уровня с названием страницы${homeMiss ? ' (на главной: чем занимается компания)' : ''}. ${HOSTED.has(platform) ? `На ${PLATFORM_LABEL[platform]} заголовок задаётся в блоке страницы: отметь нужный текст как H1 в редакторе.` : 'Если H1 не выводит шаблон темы, правь шаблон, а не каждую страницу.'}`, verify: 'На каждой проверенной странице ровно один H1. На внутренних страницах он совпадает с названием страницы, на главной говорит, чем занимается компания.' }
375
+ : { task: `On the pages without an H1 (${noneList}), add one first-level heading with the page name${homeMiss ? ' (on the home page: what the business does)' : ''}. ${HOSTED.has(platform) ? `On ${PLATFORM_LABEL[platform]} the heading is set in the page block: mark the text as H1 in the editor.` : 'If the theme template does not output an H1, fix the template rather than every page.'}`, verify: 'Every sampled page has exactly one H1: the page name on inner pages, what the business does on the home page.' };
320
376
  return null;
321
377
  },
322
378
  description: ({ check, ru }) => (/нет|missing|none|no description/i.test(String(check.value)) ? (ru
323
379
  ? { task: 'Напиши описание главной (meta description) в 120-160 знаков: чем занимается компания и что человек найдёт на сайте. Одно предложение, без рекламных восклицаний. Не обещай того, чего на сайте нет.', verify: 'У главной есть описание длиной от 120 до 160 знаков, и оно говорит, чем занимается компания.' }
324
- : { task: 'Write the home page meta description in 120 to 160 characters: what the business does and what a visitor finds. One sentence, no exclamation marks. Promise nothing the site does not have.', verify: 'The home page has a description of 120 to 160 characters saying what the business does.' }) : null),
325
- 'description-all': ({ ru }) => (ru
326
- ? { task: 'Добавь meta description на страницы, где его нет: 120-160 знаков своими словами о том, что человек найдёт именно на этой странице. Бери смысл из текста страницы. Если описание пустое из-за шаблона или плагина, почини шаблон, чтобы он брал поле описания страницы.', verify: 'На каждой проверенной странице есть meta description длиной от 120 до 160 знаков, и описания не повторяются.' }
327
- : { task: 'Add a meta description where it is missing: 120 to 160 characters, in your own words, on what this particular page offers. Take the meaning from the page text. If the template or plugin leaves it empty, fix the template so it uses the page description field.', verify: 'Every sampled page has a meta description of 120 to 160 characters, and none repeat.' }),
380
+ : { task: 'Write the home page meta description in 120 to 160 characters: what the business does and what a visitor finds. One sentence, no exclamation marks. Promise nothing the site does not have.', verify: 'The home page has a description of 120 to 160 characters saying what the business does.' }) : (() => {
381
+ // Описание есть, но не той длины: задача про длину, а не «убери служебный код», которого нет (oper-stack.ru и .com,
382
+ // прогон на своих сайтах 15.10.2026). Про код только когда он правда есть.
383
+ const v = String(check.value || '');
384
+ if (/shortcode|garbage|служебный код/i.test(v)) return null;
385
+ const n = Number((v.match(/\d+/) || [])[0]);
386
+ if (!Number.isFinite(n) || (n >= 120 && n <= 160)) return null;
387
+ const longer = n > 160;
388
+ return ru
389
+ ? { task: `${longer ? 'Сократи' : 'Дополни'} описание главной (meta description) до 120-160 знаков: сейчас ${n}. ${longer ? 'Смысл оставь, убери лишние слова.' : 'Допиши, что человек найдёт на сайте и чем компания полезна.'} Не обещай того, чего на сайте нет.`, verify: 'Описание главной занимает от 120 до 160 знаков и говорит, чем занимается компания.' }
390
+ : { task: `${longer ? 'Shorten' : 'Extend'} the home page meta description to 120 to 160 characters: it is ${n} now. ${longer ? 'Keep the meaning, cut the extra words.' : 'Add what a visitor finds on the site and why the business is useful.'} Promise nothing the site does not have.`, verify: 'The home page description is 120 to 160 characters and says what the business does.' };
391
+ })()),
392
+ 'description-all': ({ audit, ru }) => { const miss = sampleOf(audit).filter((p) => !String(p.description || '').trim()).map((p) => pathOf(p.url)).join(', '); return (ru
393
+ ? { task: `Добавь meta description на страницы, где его нет${miss ? ` (${miss})` : ''}: 120-160 знаков своими словами о том, что человек найдёт именно на этой странице. Бери смысл из текста страницы. Если описание пустое из-за шаблона или плагина, почини шаблон, чтобы он брал поле описания страницы.`, verify: 'На каждой проверенной странице есть meta description длиной от 120 до 160 знаков, и описания не повторяются.' }
394
+ : { task: `Add a meta description where it is missing${miss ? ` (${miss})` : ''}: 120 to 160 characters, in your own words, on what this particular page offers. Take the meaning from the page text. If the template or plugin leaves it empty, fix the template so it uses the page description field.`, verify: 'Every sampled page has a meta description of 120 to 160 characters, and none repeat.' }); },
328
395
  robots: ({ check, ru, platform }) => {
329
396
  const rules = Number((String(check.value).match(/(\d+)/) || [])[1]);
330
397
  const onlySitemap = /строки с картой сайта нет|no sitemap line/i.test(String(check.value)) && rules <= 2;
@@ -334,7 +401,12 @@ const PF = {
334
401
  ? { task: `${wp ? 'robots.txt на WordPress часто собирается на лету, тогда файла на диске нет: создай его в плагине SEO (в Yoast: SEO, Инструменты, Редактор файлов) и сохрани нынешние строки. ' : ''}Добавь в конец robots.txt строку Sitemap с полным адресом карты сайта. Нынешние правила не трогай: закрытых обычных страниц в них нет.`, verify: 'В robots.txt есть строка Sitemap, и адрес в ней открывает карту сайта.' }
335
402
  : { task: `${wp ? 'On WordPress robots.txt is often generated on the fly with no file on disk: create it in the SEO plugin (Yoast: SEO, Tools, File editor) and keep the current lines. ' : ''}Add a Sitemap line with the full sitemap address at the end of robots.txt. Leave the current rules alone: they block no ordinary pages.`, verify: 'robots.txt has a Sitemap line, and its address opens the sitemap.' };
336
403
  },
337
- 'faq-schema': ({ ru, v }) => (ru
404
+ // Блок вопросов на сайте уже есть, разметки нет: задача разметить его, а не собирать вопросы заново (pilatesvw.ru).
405
+ 'faq-schema': ({ audit, ru, v }) => { const has = sampleOf(audit).filter((p) => p.faqBlock && !(p.schemaTypes || []).includes('FAQPage')).map((p) => pathOf(p.url)); if (has.length) return ru
406
+ ? { task: `Блок вопросов и ответов на сайте уже есть (${has.join(', ')}), но без разметки. Разметьте его схемой FAQPage в JSON-LD: каждый вопрос и ответ слово в слово, как на странице. Новых вопросов не добавляйте.`, verify: 'На этих страницах есть разметка FAQPage, и её вопросы и ответы совпадают с видимым блоком.' }
407
+ : { task: `The site already has a questions-and-answers block (${has.join(', ')}) without markup. Mark it up as FAQPage in JSON-LD: each question and answer word for word as on the page. Add no new questions.`, verify: 'Those pages carry FAQPage markup whose questions and answers match the visible block.' };
408
+ return PF_FAQ_NEW({ ru, v }); },
409
+ 'faq-schema-old': ({ ru, v }) => (ru
338
410
  ? { task: `Собери с владельцем 5-7 частых вопросов ${v.peopleGen} с ответами, опубликуй их видимым блоком на страницах главных услуг и только потом разметь схемой FAQPage. Текст в разметке должен слово в слово совпадать с видимым. Вопросов, которых нет на странице, в разметку не добавляй.`, verify: 'На выбранных страницах есть разметка FAQPage минимум с пятью вопросами, и каждый вопрос и ответ из неё дословно виден на странице.' }
339
411
  : { task: `Collect 5 to 7 frequent questions from ${v.peopleGen} with the owner, publish them as a visible block on the main service pages, and only then mark them up with FAQPage. The markup text must match the visible text word for word.`, verify: 'The chosen pages carry FAQPage markup with at least five questions, each question and answer visible on the page word for word.' }),
340
412
  // В разметке только мессенджеры: задача не «впиши то, что уже вписано», а найти настоящие профили (cimes.ru, 15.10.2026).
@@ -354,16 +426,25 @@ const PF = {
354
426
  sections: ({ audit, ru }) => {
355
427
  // Страница-список (CollectionPage, ItemList) разводит по подразделам, разбивать её на разделы незачем (cimes.ru, 14.10.2026).
356
428
  const long = sampleOf(audit).filter((p) => (p.words || 0) >= 600 && (p.h2Count || 0) < 3 && !isListPage(p));
357
- if (!long.length) return null;
358
- const list = long.slice(0, 5).map((p) => pathOf(p.url)).join(', ');
429
+ // Длинных нет: задача всё равно называет страницы и говорит прямо, что разбивать их сейчас не обязательно (oper-stack.ru,
430
+ // прогон на своих сайтах 15.10.2026: «меньше трёх H2 на 3 из 20» без адресов).
431
+ if (!long.length) {
432
+ const few0 = sampleOf(audit).filter((p) => (p.h2Count || 0) < 3).map((p) => pathOf(p.url));
433
+ if (!few0.length) return null;
434
+ return ru
435
+ ? { task: `Меньше трёх подзаголовков H2 на страницах: ${few0.join(', ')}. Все они короче 600 слов или это страницы-списки, поэтому разбивать их сейчас не обязательно. Когда будешь дописывать такую страницу, начинай каждый новый раздел заголовком H2 по смыслу текста.`, verify: `Дописанные страницы из списка (${few0.join(', ')}) разбиты заголовками H2 по разделам; недописанные оставлены как есть.` }
436
+ : { task: `Fewer than three H2 subheadings on: ${few0.join(', ')}. All of them are under 600 words or are list pages, so splitting them now is not required. When you extend such a page, start each new section with an H2 that follows the text.`, verify: `The pages from the list (${few0.join(', ')}) that you extend are split by H2s; the others are left as they are.` };
437
+ }
438
+ // Все страницы, без «первых пяти»: проверяющий находил пропущенные и считал это ошибкой (marilav.ru, svetak.ru, 15.10.2026).
439
+ const list = long.map((p) => pathOf(p.url)).join(', ');
359
440
  // В отчёте «меньше трёх H2» считается по всем страницам выборки, а в задачу идут только длинные: правило отбора пишем в тексте (cimes.ru, 14.10.2026).
360
441
  const few = sampleOf(audit).filter((p) => (p.h2Count || 0) < 3);
361
442
  const rest = few.filter((p) => !long.includes(p));
362
443
  const isList = isListPage;
363
444
  const shortOnes = rest.filter((p) => !isList(p)); const lists = rest.filter(isList);
364
445
  const why = rest.length ? (ru
365
- ? ` Из ${few.length} страниц выборки, где меньше трёх H2, сюда вошли страницы с обычным текстом от 600 слов.${shortOnes.length ? ` Короткие (${shortOnes.slice(0, 5).map((p) => pathOf(p.url)).join(', ')}) разбивать не нужно.` : ''}${lists.length ? ` Страницы-списки подразделов (${lists.slice(0, 5).map((p) => pathOf(p.url)).join(', ')}) ведут на другие страницы, разбивать их на разделы не нужно.` : ''}`
366
- : ` Of the ${few.length} sampled pages with fewer than three H2s only the long ones, 600 words and more, are listed. The others (${rest.slice(0, 5).map((p) => pathOf(p.url)).join(', ')}) are short or are list pages: leave them.`) : '';
446
+ ? ` Из ${few.length} страниц выборки, где меньше трёх H2, ${long.length === 1 ? 'сюда вошла страница' : 'сюда вошли страницы'} с обычным текстом от 600 слов.${shortOnes.length ? ` ${shortOnes.length === 1 ? 'Короткую' : 'Короткие'} (${shortOnes.map((p) => `${pathOf(p.url)}, ${p.words} сл.`).join('; ')}) разбивать не нужно.` : ''}${lists.length ? ` ${lists.length === 1 ? 'Страница-список' : 'Страницы-списки'} подразделов (${lists.map((p) => pathOf(p.url)).join(', ')}) ${lists.length === 1 ? 'ведёт' : 'ведут'} на другие страницы, разбивать ${lists.length === 1 ? 'её' : 'их'} на разделы не нужно.` : ''}`
447
+ : ` Of the ${few.length} sampled pages with fewer than three H2s only the long ones, 600 words and more, are listed. The others (${rest.map((p) => pathOf(p.url)).join(', ')}) are short or are list pages: leave ${rest.length === 1 ? 'it' : 'them'}.`) : '';
367
448
  // Подзаголовки уже есть, но размечены H3: задача не «придумать H2», а поднять существующие (14.10.2026).
368
449
  if (long.every((p) => (p.h3Count || 0) >= 2)) return ru
369
450
  ? { task: `На ${long.length === 1 ? 'странице' : 'страницах'} ${list} подзаголовки уже есть, но размечены как H3. Сделай существующие смысловые подзаголовки H2; новые добавляй, только если без них раздел не читается. Текст не дописывай.${why}`, verify: `${long.length === 1 ? 'На странице' : 'На страницах'} ${list} не меньше трёх заголовков H2 по смыслу текста.` }
@@ -376,8 +457,8 @@ const PF = {
376
457
  ? { task: `Поставь на первый экран каждой главной страницы одно понятное действие, которое на сайте уже есть: ${v.cta}. Одно действие на экран. Не выдумывай новых обещаний и сроков.`, verify: 'На каждой такой странице кнопка или ссылка с этим действием видна на первом экране без прокрутки, в основном блоке страницы, а не в шапке.' }
377
458
  : { task: `Put one clear action the site already offers into the first screen of every main page: ${v.cta}. One action per screen. Invent no new promises or timings.`, verify: 'Each such page shows that button or link in the first screen without scrolling, in the main content block rather than the header.' }),
378
459
  'conv-messenger': ({ ru }) => (ru
379
- ? { task: 'Спроси владельца, есть ли у компании мессенджер для связи (WhatsApp, Telegram, MAX). Если есть, поставь одну ссылку рядом с телефоном, чтобы она открывала диалог. Если нет, задачу закрой.', verify: 'Либо рядом с телефоном есть рабочая ссылка на мессенджер, либо владелец подтвердил, что его нет.' }
380
- : { task: 'Ask the owner whether the business uses a messenger (WhatsApp, Telegram and the like). If yes, put one link next to the phone that opens a conversation. If no, close the task.', verify: 'Either a working messenger link sits next to the phone, or the owner confirmed there is none.' }),
460
+ ? { task: 'Спроси владельца, есть ли у компании мессенджер для связи (WhatsApp, Telegram, MAX). Если есть, поставь одну ссылку в шапке или рядом с другими контактами, чтобы она открывала диалог. Если нет, задачу закрой.', verify: 'Либо в шапке или рядом с контактами есть рабочая ссылка на мессенджер, либо владелец подтвердил, что его нет.' }
461
+ : { task: 'Ask the owner whether the business uses a messenger (WhatsApp, Telegram and the like). If yes, put one link in the header or next to the other contacts that opens a conversation. If no, close the task.', verify: 'Either a working messenger link sits in the header or next to the other contacts, or the owner confirmed there is none.' }),
381
462
  'org-schema': ({ audit, ru, v }) => {
382
463
  const links = (audit?.checks || []).find((c) => c.id === 'trust-profiles');
383
464
  const has = /ссылки на сайте есть|pages link to/i.test(String(links?.value || ''));
@@ -391,7 +472,13 @@ const PF = {
391
472
  utility: ({ check, ru, platform }) => (/author/i.test(String(check.value)) && platform === 'wordpress' ? (ru
392
473
  ? { task: 'Выключи архивы авторов в плагине SEO (в Yoast: SEO, Отображение в поисковой выдаче, вкладка Архивы; в английском интерфейсе Search Appearance, Archives), тогда страница автора уйдёт из карты сайта. Если имя автора выглядит как логин (например, admin), смени отображаемое имя в профиле пользователя WordPress на название компании.', verify: 'В карте сайта нет страниц авторов, и в разметке страниц нет имени-логина.' }
393
474
  : { task: 'Switch off author archives in the SEO plugin (Yoast: Search Appearance, Archives), so the author page leaves the sitemap. If the author name looks like a login (admin, say), change the display name in the WordPress user profile to the company name.', verify: 'The sitemap has no author pages, and the page markup carries no login-like name.' }) : null),
394
- ttfb: ({ ru }) => (ru
475
+ /*
476
+ * Конструктор (Tilda, Wix, Squarespace, Shopify): сервер, кеш и сборка у платформы, «плагин кеша» и «объедини файлы
477
+ * при сборке» исполнитель сделать не может (проверяющий, smirnov-xo.ru и babynanny.ru на Tilda, 15.10.2026).
478
+ */
479
+ ttfb: ({ ru, platform }) => (HOSTED.has(platform) ? (ru
480
+ ? { task: `Сначала сделай три замера из страны, где живут посетители сайта. Сервер и кеш у ${PLATFORM_LABEL[platform]} настраивает сама платформа, поэтому если ответ больше 0,8 секунды, убери с главной то, что грузится до текста: тяжёлое видео и большие картинки в первом блоке, лишние виджеты и вставки чужого кода. Менять платформу только из-за нашего замера не нужно: мы мерили с сервера в Германии.`, verify: 'Три замера подряд из страны посетителей показывают 0,8 секунды или меньше.' }
481
+ : { task: `First take three measurements from the country your visitors live in. ${PLATFORM_LABEL[platform]} runs the server and the cache itself, so if the answer takes over 0.8 seconds, cut what loads before the text on the home page: heavy video and big images in the first block, extra widgets and embedded third-party code. Do not move platform on our measurement alone: we measure from Germany.`, verify: 'Three runs in a row from your visitors\' country show 0.8 seconds or less.' }) : null) || (ru
395
482
  ? { task: 'Сначала сделай три замера из страны, где живут посетители сайта. Если ответ больше 0,8 секунды (порог, который Google считает хорошим), включи кеширование готовых страниц: плагин кеша, кеш хостинга или CDN. Менять хостинг только из-за нашего замера не нужно: мы мерили с сервера в Германии, а не из страны посетителей сайта.', verify: 'Три замера подряд из страны посетителей (curl -o /dev/null -w "%{time_starttransfer}" адрес главной) показывают не больше 0,8 секунды.' }
396
483
  : { task: 'First take three measurements from the country your visitors live in. If the answer takes over 0.8 seconds (the level Google calls good), turn on page caching: a cache plugin, host cache or a CDN. Do not move host on our measurement alone: we measure from elsewhere.', verify: 'Three runs in a row from your visitors\' country (curl -o /dev/null -w "%{time_starttransfer}" home URL) show 0.8 seconds or less.' }),
397
484
  thin: ({ audit, ru, v }) => {
@@ -406,14 +493,15 @@ const PF = {
406
493
  ? { task: `Короткие страницы выборки это списки и отзывы (${sm.filter((p) => (p.words || 0) < 300).map((p) => pathOf(p.url)).join(', ')}): дописывать их не нужно. Если отзывы подгружаются виджетом, проверь, что хотя бы несколько отзывов видны в самом коде страницы, иначе поиск и ИИ их не читают.`, verify: 'На странице отзывов несколько отзывов видны в исходном коде страницы, а не только в виджете.' }
407
494
  : { task: `The short sampled pages are lists and reviews (${sm.filter((p) => (p.words || 0) < 300).map((p) => pathOf(p.url)).join(', ')}): no need to pad them. If reviews load through a widget, make sure a few reviews are present in the page source, otherwise search and AI cannot read them.`, verify: 'A few reviews are visible in the page source, not only in the widget.' };
408
495
  if (templated.length >= 2) {
409
- const list = templated.slice(0, 4).map((p) => pathOf(p.url)).join(', ');
496
+ const list = templated.map((p) => pathOf(p.url)).join(', ');
410
497
  return ru
411
498
  ? { task: `На ${templated.length} страницах выборки (например, ${list}) своего текста почти нет: шаблон показывает блоки главной вместо содержания записи. Сделай для таких записей свой шаблон: заголовок H1 с названием (для сотрудника ФИО), а дальше данные записи (должность, филиал, опыт). Если записи не нужны в поиске, закрой их noindex. Факты бери только у владельца.`, verify: 'На этих страницах есть свой H1 и свои данные записи, а блоки главной на них больше не повторяются (или страницы закрыты noindex по решению владельца).' }
412
499
  : { task: `On ${templated.length} sampled pages (e.g. ${list}) there is almost no own text: the template shows home page blocks instead of the post. Give such posts their own template: an H1 with the name, then the post's own data. If they do not need to be in search, set noindex. Take facts only from the owner.`, verify: 'These pages carry their own H1 and data, and no longer repeat the home page blocks (or are noindexed by the owner\'s decision).' };
413
500
  }
501
+ const shortList = shortReal.map((p) => pathOf(p.url)).join(', ');
414
502
  return ru
415
- ? { task: `Допиши короткие страницы фактами, которые знает владелец и которые нужны ${v.peopleDat || v.people}: что это, кому, как, где и когда. ${v.price} Не разбавляй текст водой ради объёма. Если фактов нет, выпиши владельцу список вопросов.`, verify: 'Каждая дописанная страница стала длиннее 300 слов за счёт фактов (названия, условия, адреса, сроки), а не общих слов.' }
416
- : { task: `Fill the short pages with facts the owner has and ${v.people} need: what it is, for whom, how, where and when. ${v.price} No filler for length. If there are no facts, list questions for the owner.`, verify: 'Each page you filled is now over 300 words thanks to facts (names, terms, places, timings), not general phrases.' };
503
+ ? { task: `Допиши короткие страницы${shortList ? ` (${shortList})` : ''} фактами, которые знает владелец и которые нужны ${v.peopleDat || v.people}: что это, кому, как, где и когда. ${v.price} Не разбавляй текст водой ради объёма. Если фактов нет, выпиши владельцу список вопросов.`, verify: 'Каждая дописанная страница стала длиннее 300 слов за счёт фактов (названия, условия, адреса, сроки), а не общих слов.' }
504
+ : { task: `Fill the short pages${shortList ? ` (${shortList})` : ''} with facts the owner has and ${v.people} need: what it is, for whom, how, where and when. ${v.price} No filler for length. If there are no facts, list questions for the owner.`, verify: 'Each page you filled is now over 300 words thanks to facts (names, terms, places, timings), not general phrases.' };
417
505
  },
418
506
  'trust-policies': ({ audit, ru }) => (audit?.verification?.consentBox ? (ru
419
507
  ? { task: 'Галочка согласия на обработку данных у формы уже есть, не хватает самой политики конфиденциальности. Её текст готовит владелец или юрист, не сочиняй. Когда текст будет, опубликуй его отдельной страницей, поставь ссылку на неё в подвал и рядом с галочкой согласия у формы.', verify: 'Страница политики конфиденциальности открывается с кодом 200, ссылка на неё есть в подвале каждой страницы и рядом с галочкой согласия у формы.' }
@@ -424,11 +512,20 @@ const PF = {
424
512
  'trust-profiles': (ctx) => PF['trust-profiles-messengers'](ctx) || (/ссылки на сайте есть|pages link to/i.test(String(ctx.check.value)) ? (ctx.ru
425
513
  ? { task: 'Профили, на которые сайт уже ссылается, впиши в поле sameAs разметки организации. Спроси владельца, есть ли другие настоящие профили (карты, отраслевые каталоги, соцсети), и добавь только подтверждённые.', verify: 'В разметке организации есть sameAs со ссылками на профили, которые уже стоят на сайте, и каждая открывает профиль этой компании.' }
426
514
  : { task: 'Put the profiles the site already links to into the sameAs field of the organisation markup. Ask the owner for any other real profiles (maps, directories, social) and add only confirmed ones.', verify: 'The organisation markup carries sameAs with the profiles already linked on the site, each opening this company\'s profile.' }) : null),
427
- sources: ({ ru }) => (ru
515
+ // Страницы, где цифры без источника, по адресам: «на каких 10 страницах?» проверяющий спрашивал на каждом сайте (15.10.2026).
516
+ sources: ({ audit, ru }) => {
517
+ const SELF = /^(Product|Offer|Service|AggregateOffer|SoftwareApplication|Course|Event|JobPosting|Recipe|RealEstateListing)$/;
518
+ const pages = sampleOf(audit).filter((p) => p.figureParagraphs > 0 && !(p.schemaTypes || []).some((t) => SELF.test(t)) && !(p.sourcePhrases > 0 || p.citedParagraphs > 0) && !isLegalPage(p) && !isContactPage(p)).map((p) => pathOf(p.url));
519
+ if (!pages.length) return null;
520
+ return ru
521
+ ? { task: `Подпиши источник к цифрам статистики (доли, количества людей, сравнения «в N раз») на страницах: ${pages.join(', ')}. Ссылкой на первоисточник или словами. Цены, адреса, телефоны и сроки услуг источника не требуют. Если источника нет ни у тебя, ни у владельца, убери цифру. Ссылку не выдумывай.`, verify: 'На этих страницах у каждой цифры статистики рядом ссылка или названный источник, либо цифра убрана.' }
522
+ : { task: `Add a source to the statistics (shares, counts of people, "N times" comparisons) on these pages: ${pages.join(', ')}. A link to the original or a named source. Prices, addresses, phones and service timings need none. If neither you nor the owner has a source, remove the figure. Never guess a link.`, verify: 'On those pages every statistic has a link or a named source beside it, or has been removed.' };
523
+ },
524
+ sourcesAll: ({ ru }) => (ru
428
525
  ? { task: 'Подпиши источник к каждой цифре статистики на сайте (доли, количества людей, сравнения «в N раз»): ссылкой на первоисточник или словами. Цены, адреса, телефоны и сроки услуг источника не требуют. Если источника нет ни у тебя, ни у владельца, цифру убери. Не ставь ссылку наугад.', verify: 'Рядом с каждой цифрой статистики есть ссылка на первоисточник или названный словами источник, либо цифра убрана.' }
429
526
  : { task: 'Add a source to every statistic on the site (shares, counts of people, "N times" comparisons): a link to the original or a named source. Prices, addresses, phones and service timings need none. If neither you nor the owner has a source, remove the figure. Never guess a link.', verify: 'Every statistic has a link or a named source beside it, or has been removed.' }),
430
527
  // Какие страницы переписывать, называем адресами: «главные страницы» исполнителю ничего не говорит (cimes.ru, 14.10.2026).
431
- 'answer-first': ({ audit, ru, v }) => { const noAnswer = sampleOf(audit).filter((p) => !p.answerFirst); const miss = noAnswer.filter((p) => !isListPage(p)).slice(0, 12).map((p) => pathOf(p.url)); const lists = noAnswer.filter(isListPage).map((p) => pathOf(p.url)); const where = (miss.length ? (ru ? ` Страницы выборки без такого начала: ${miss.join(', ')}.` : ` Sampled pages without such an opening: ${miss.join(', ')}.`) : '') + (lists.length ? (ru ? ` Остальные (${lists.slice(0, 5).join(', ')}) это страницы-списки подразделов, их начало переписывать не нужно.` : ` The others (${lists.slice(0, 5).join(', ')}) are list pages leading to sub-pages: leave their opening as it is.`) : ''); const r = (ru
528
+ 'answer-first': ({ audit, ru, v }) => { const legal = sampleOf(audit).filter((p) => !p.answerFirst && isLegalPage(p)).map((p) => pathOf(p.url)); const noAnswer = sampleOf(audit).filter((p) => !p.answerFirst && !isLegalPage(p)); const miss = noAnswer.filter((p) => !isListPage(p)).slice(0, 25).map((p) => `${pathOf(p.url)}${p.answerNear ? (ru ? ` (под заголовком уже есть фраза с цифрой «${p.answerNear}»: разверни её в абзац на 20-90 слов)` : ` (a line with a figure already sits under the heading, "${p.answerNear}": expand it into a 20 to 90 word paragraph)`) : /(?<![\p{L}\d])\d+(?:[\s.,]\d+)*/u.test(String(p.h1 || '')) ? (ru ? ' (цифра уже в заголовке, нужен абзац-ответ под ним)' : ' (the figure is already in the heading; it needs an answer paragraph under it)') : ''}`); const lists = noAnswer.filter(isListPage).map((p) => pathOf(p.url)); const where = (miss.length ? (ru ? ` Страницы выборки без такого начала: ${miss.join(', ')}.` : ` Sampled pages without such an opening: ${miss.join(', ')}.`) : '') + (lists.length ? (ru ? ` Остальные (${lists.slice(0, 5).join(', ')}) это страницы-списки подразделов, их начало переписывать не нужно.` : ` The others (${lists.slice(0, 5).join(', ')}) are list pages leading to sub-pages: leave their opening as it is.`) : '') + (legal.length ? (ru ? ` Юридическим страницам (${legal.slice(0, 5).join(', ')}) ответ с цифрой не нужен, их не трогай.` : ` Legal pages (${legal.slice(0, 5).join(', ')}) need no such answer: leave them.`) : ''); const r = (ru
432
529
  ? { task: `Перепиши начало главных страниц так, чтобы первый абзац сразу отвечал на вопрос ${v.peopleGen}: что это, кому и на каких условиях. От двадцати до девяноста слов, с конкретной цифрой из фактов владельца; источник цифры назови рядом (у собственных цен и сроков источник сам владелец, отдельно его называть не нужно). Если цифры нет, не придумывай: спроси владельца.`, verify: 'Первый абзац после заголовка на этих страницах укладывается в 20-90 слов и содержит цифру; если цифра не своя (не цена и не срок владельца), рядом назван источник.' }
433
530
  : { task: `Rewrite the opening of the main pages so the first paragraph answers what ${v.people} ask: what it is, for whom, on what terms. Twenty to ninety words with a concrete figure from the owner's facts, its source named beside it (the owner's own prices and timings need no source). If there is no figure, do not invent one: ask the owner.`, verify: 'The first paragraph after the heading on those pages is 20 to 90 words and carries a figure; if the figure is not the owner\'s own price or timing, its source is named beside it.' }); return { ...r, task: `${r.task}${where}` }; },
434
531
 
@@ -497,7 +594,7 @@ const WHERE = {
497
594
  },
498
595
  };
499
596
 
500
- const PLATFORM_LABEL = { wordpress: 'WordPress', astro: 'Astro', next: 'Next.js', shopify: 'Shopify' };
597
+ const PLATFORM_LABEL = { wordpress: 'WordPress', astro: 'Astro', next: 'Next.js', shopify: 'Shopify', tilda: 'Tilda', wix: 'Wix', squarespace: 'Squarespace', bitrix: '1С-Битрикс' };
501
598
 
502
599
  /** На чём собран сайт, по словам самого сайта. Не угадываем: молчит, значит показываем все. */
503
600
  export function detectPlatform(audit) {
@@ -507,6 +604,10 @@ export function detectPlatform(audit) {
507
604
  if (/astro/.test(said)) return 'astro';
508
605
  if (/next/.test(said)) return 'next';
509
606
  if (/shopify/.test(said)) return 'shopify';
607
+ if (/tilda/.test(said)) return 'tilda';
608
+ if (/\bwix\b/.test(said)) return 'wix';
609
+ if (/squarespace/.test(said)) return 'squarespace';
610
+ if (/bitrix|битрикс/.test(said)) return 'bitrix';
510
611
  return null;
511
612
  }
512
613
 
@@ -527,9 +628,12 @@ export function agentPrompt(check, opts = {}) {
527
628
  const noForm = check.id === 'conv-form-depth' && /no form|формы нет/i.test(String(check.value || ''));
528
629
  const key = noForm ? 'conv-form-missing' : promptKey(check.id);
529
630
  // Задание по замеру (PF) важнее шаблонного (P): шаблон не знает, что на этом сайте на самом деле.
530
- const ctx = { check, audit: opts.audit || null, lang, ru: lang === 'ru', v: vocabFor(opts.audit || {}, lang), platform: opts.platform || null };
631
+ const ctx = { check, audit: opts.audit || null, lang, ru: lang === 'ru', v: vocabFor(opts.audit || {}, lang), platform: opts.platform || (opts.audit ? detectPlatform(opts.audit) : null) };
531
632
  const measured = PF[key] ? PF[key](ctx) : null;
532
- const base = measured || (P[key] ? withLang(P[key], lang) : null);
633
+ const raw = measured || (P[key] ? withLang(P[key], lang) : null);
634
+ // «Страницы услуг» только там, где сайт продаёт услуги: у магазина это страницы каталога и товаров (15.10.2026).
635
+ const niched = (t) => String(t || '').replace(/страниц главных услуг|страниц услуг/g, ctx.v.mainGen || 'основных страниц').replace(/страницах главных услуг|страницах услуг/g, ctx.v.mainLoc || 'основных страницах').replace(/(?:the )?main service pages|(?:the )?service pages/g, `the ${ctx.v.mainPages || 'main pages'}`);
636
+ const base = raw && ['conv-form-missing', 'faq-schema', 'faq-schema-old'].includes(key) ? { ...raw, task: niched(raw.task), verify: niched(raw.verify) } : raw;
533
637
  // Правка проверяющего ИИ (review.mjs) важнее любого шаблона: она сверена с сайтом.
534
638
  const over = opts.audit?.taskOverrides?.[check.id];
535
639
  const own = over ? { task: over.task || base?.task || check.label, verify: over.verify || base?.verify || HEAD[lang].genericVerify } : base;
@@ -542,13 +646,28 @@ export function agentPrompt(check, opts = {}) {
542
646
  lines.push('', `**${h.verify}:** ${own ? own.verify : h.genericVerify}`);
543
647
  const where = whereLine(check.id, opts.platform || null, lang);
544
648
  if (where) lines.push('', where);
545
- if (opts.urls && opts.urls.length) { lines.push('', `**${h.pages}:** ${opts.urls.slice(0, 8).join(', ')}`); }
649
+ if (opts.urls && opts.urls.length) { lines.push('', `**${h.pages}:** ${opts.urls.join(', ')}`); }
546
650
  lines.push('', h.rule);
547
- return lines.join('\n');
651
+ return lang === 'ru' ? toVy(lines.join('\n')) : lines.join('\n'); // клиент читает задачи сам: на «вы» (15.10.2026)
548
652
  }
549
653
 
550
654
  /** Все задания по открытым находкам аудита, в порядке важности. */
551
- const datesGeneric = ({ ru }) => (ru
655
+ // Где дата только в разметке, по адресам; про сортировку записей только для WordPress (проверяющий: «если тема сортирует
656
+ // записи» на Tilda непонятно, 15.10.2026).
657
+ const datesGeneric = ({ ru, audit = null, platform = null }) => {
658
+ // Служебные страницы в список не ставим: «покажите дату на /contact/», а строкой ниже «контактам дата не нужна» (moregroup.estate, 16.10.2026).
659
+ const marked = sampleOf(audit || {}).filter((p) => p.dates && (p.dates.published || p.dates.modified) && !p.dates.visible);
660
+ const utility = marked.filter((p) => isLegalPage(p) || isContactPage(p)).map((p) => pathOf(p.url));
661
+ const only = marked.filter((p) => !isLegalPage(p) && !isContactPage(p)).map((p) => pathOf(p.url));
662
+ if (!only.length && utility.length) return ru
663
+ ? { task: `Ничего добавлять не нужно: дата есть только в разметке на служебных страницах (${utility.join(', ')}), а видимая дата им не нужна.`, verify: 'Задача закрыта без правок.' }
664
+ : { task: `Nothing to add: the markup date without a visible date is only on utility pages (${utility.join(', ')}), which need no visible date.`, verify: 'The task is closed without edits.' };
665
+ if (only.length && platform !== 'wordpress') return ru
666
+ ? { task: `Выведи дату публикации видимой строкой на страницах, где она сейчас есть только в разметке: ${only.join(', ')}. Дата в тексте должна совпадать с датой в разметке (datePublished). Служебным страницам (контакты, политика) дата не нужна, их пропусти.`, verify: 'На этих страницах видна дата публикации, и она совпадает с datePublished в разметке.' }
667
+ : { task: `Show the published date as a visible line on the pages that carry it only in the markup now: ${only.join(', ')}. The visible date must match the markup (datePublished). Utility pages (contact, policies) need no date: skip them.`, verify: 'Those pages show the published date, and it matches datePublished in the markup.' };
668
+ return datesGenericWp({ ru });
669
+ };
670
+ const datesGenericWp = ({ ru }) => (ru
552
671
  ? { task: 'Сверь даты публикации в разметке (datePublished) с настоящими датами материалов: если тема сортирует записи по этой дате, сначала переключи сортировку на настоящую дату, потом исправь даты в разметке. Затем выведи дату публикации видимой строкой на странице каждого материала.', verify: 'На странице каждого материала видна дата публикации, и она совпадает с datePublished в его разметке.' }
553
672
  : { task: 'Check the published dates in the markup (datePublished) against the real dates of the posts: if the theme sorts posts by that date, switch the sorting first, then correct the markup dates. Then show the published date as a visible line on every post.', verify: 'Every post shows its published date on the page, and it matches datePublished in its markup.' });
554
673
 
@@ -662,5 +781,7 @@ export function firstFixParts(finding, opts = {}) {
662
781
  const own = FIRST_FIX_OWN[id] ? withLang(FIRST_FIX_OWN[id], lang)
663
782
  : (FIRST_FIX_BY_PROMPT[id] && P[FIRST_FIX_BY_PROMPT[id]] ? withLang(P[FIRST_FIX_BY_PROMPT[id]], lang) : null);
664
783
  if (!own || !own.task || !own.verify) return null;
665
- return { now, task: own.task, verify: own.verify, rule: h.rule, labels: { now: h.now, task: h.task, verify: h.verify } };
784
+ // Первую правку читает клиент на странице проверки: на «вы», как подпись под ней (15.10.2026).
785
+ const vy = lang === 'ru' ? toVy : (x) => x;
786
+ return { now: vy(now), task: vy(own.task), verify: vy(own.verify), rule: vy(h.rule), labels: { now: h.now, task: h.task, verify: h.verify } };
666
787
  }