@operstack/audit 0.35.1 → 0.36.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/narrative.mjs CHANGED
@@ -31,23 +31,23 @@ const T = {
31
31
  */
32
32
  sells: (d, p) => d ? `${d} (прочитано из описания главной страницы)` : p ? `по тексту главной: «${p}»; описания для поиска у главной нет` : 'описания для поиска у главной нет, а из текста главной это не понять',
33
33
  leadWhat: (h, n) => `Мы прочитали ${n} ${plural(n, 'страницу', 'страницы', 'страниц')} сайта ${h} так же, как их открывают поисковик и ИИ.`,
34
- leadGood: (areas) => areas.length ? `Хорошо сделано вот что: ${listRu(areas)}.` : 'Ни одна из шести областей пока не выглядит сильной стороной.',
35
- leadBad: (area, n) => n ? `Тянет сайт вниз другое: ${n} ${plural(n, 'проверка провалена', 'проверки провалены', 'проверок провалено')}, тяжелее всего дела в области «${area}».` : `Проваленных проверок нет, но до потолка не хватает мелочей, тяжелее всего в области «${area}».`,
34
+ leadGood: (areas) => areas.length === 6 ? 'Все шесть областей проверки в хорошем состоянии.' : areas.length ? `Хорошо сделано вот что: ${listRu(areas)}.` : 'Ни одна из шести областей пока не выглядит сильной стороной.',
35
+ leadBad: (area, n, partial = 0) => n ? `Тянет сайт вниз другое: ${n} ${plural(n, 'проверка провалена', 'проверки провалены', 'проверок провалено')}${area ? `, тяжелее всего дела в области «${area}»` : ''}.` : area ? `Проваленных проверок нет, но до потолка не хватает мелочей, тяжелее всего в области «${area}».` : `Проваленных проверок нет${partial ? `, не полностью пройдены ${partial} ${plural(partial, 'проверка', 'проверки', 'проверок')}, они ниже` : ''}.`,
36
36
  verdict: (openN, mech, people = 'покупателей') => openN
37
- ? `${openN} ${plural(openN, 'проверка пройдена', 'проверки пройдены', 'проверок пройдены')} не полностью. Из них ${mech} ${plural(mech, 'правится', 'правятся', 'правятся')} технически, без переписывания текстов, то есть быстро, а ${openN - mech} ${plural(openN - mech, 'потребует', 'потребуют', 'потребуют')} ваших фактов: цифр, источников, ответов на вопросы ${people}.`
37
+ ? `${openN} ${plural(openN, 'проверка пройдена', 'проверки пройдены', 'проверок пройдены')} не полностью. ${mech === 0 ? `Все они требуют ваших фактов: цифр, источников, ответов на вопросы ${people}.` : mech === openN ? 'Все они правятся технически, без переписывания текстов, то есть быстро.' : `Из них ${mech} ${plural(mech, 'правится', 'правятся', 'правятся')} технически, без переписывания текстов, то есть быстро, а ${openN - mech} ${plural(openN - mech, 'потребует', 'потребуют', 'потребуют')} ваших фактов: цифр, источников, ответов на вопросы ${people}.`}`
38
38
  : `Главное: критичных поломок нет. Дальше сайт растёт не починкой, а текстами: ответами на вопросы ${people}, цифрами и источниками, по которым вас можно процитировать.`,
39
39
  noteOffer: (cta, contact) => `Что видит человек, попавший на сайт: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
40
40
  ctaYes: 'на первом экране есть понятный призыв к действию',
41
41
  ctaNo: 'на первом экране нечего нажать, и это значит, что пришедший уходит',
42
42
  ctaSome: 'призыв к действию на первом экране есть не на всех страницах',
43
- contactYes: 'связаться можно почти с любой страницы',
43
+ contactYes: 'связаться можно с любой проверенной страницы',
44
44
  contactNo: 'связаться с большинства страниц нельзя, человеку приходится искать',
45
45
  strengthNone: 'Сильных сторон в текстах проверки не нашли.',
46
- weakNone: 'Слабых мест в текстах проверки не нашли.',
46
+ weakNone: 'В текстах существующих страниц слабых мест не нашли.',
47
47
  gapsNone: 'Пробелов по страницам из выборки не видно.',
48
48
  aeoWorksNone: 'Пока ничего из того, что помогает попасть в ответы, на сайте не работает.',
49
49
  aeoBlocksNone: 'Прямых помех для попадания в ответы не нашли.',
50
- aeoRec: (p) => p ? `Начните с одной страницы: ${p}. Поставьте в её начало прямой ответ с цифрой и назовите источник этой цифры рядом. Так страницу можно процитировать.` : 'Поставьте в начало каждой важной страницы прямой ответ с цифрой и назовите рядом источник. Цитируют то, что можно вырезать одним абзацем.',
50
+ aeoRec: (p) => p ? `Начните с одной страницы: ${p}. Поставьте в её начало прямой ответ с цифрой; если цифра не ваша цена или срок, назовите рядом её источник. Так страницу можно процитировать.` : 'Поставьте в начало каждой важной страницы прямой ответ с цифрой и назовите рядом источник. Цитируют то, что можно вырезать одним абзацем.',
51
51
  geoEntity: ['Кто вы, понятно машине', (ok) => ok ? 'да' : 'нет'],
52
52
  notMeasured: 'не измеряли',
53
53
  geoMentions: 'Упоминания на чужих сайтах',
@@ -56,14 +56,14 @@ const T = {
56
56
  geoCallout: (ok) => ok
57
57
  ? 'Роботы, которые достают страницу, чтобы процитировать её в ответе, на сайт пущены. Это половина дела: дальше вопрос в том, есть ли что цитировать.'
58
58
  : 'Роботы, которые достают страницу для цитаты в ответе ИИ, на сайт не пущены. Пока это так, попасть в ответы невозможно физически, чего бы вы ни написали.',
59
- offpageListed: (profiles) => profiles.length ? `Заявлены профили: ${profiles.join(', ')}.` : 'Ни одного профиля компании на других площадках в коде сайта не заявлено.',
59
+ offpageListed: (profiles) => profiles.length ? '' : 'Ни одного профиля компании на других площадках в коде сайта не заявлено.',
60
60
  offpageNote: 'Мы не покупаем данные о ссылках, поэтому не пишем ни цифр про их количество, ни оценок за них. Что проверяемо бесплатно: сказал ли сайт сам, где ещё вас искать, и совпадает ли это с реальностью.',
61
61
  convForm: (v) => v,
62
62
  priceRow: 'Цена видна',
63
63
  priceNo: 'на проверенных страницах цены нет',
64
64
  priceYes: (n, total) => `да: цена есть на ${n} из ${total} проверенных страниц`,
65
65
  closing: (host, bad) => bad
66
- ? `Мы посмотрели ${host} глазами машины и написали, что увидели, без общих слов. Сначала делается то, что чинится технически: это быстро и это видно в цифрах. Потом берутся тексты, и вот там понадобитесь вы, потому что цифры и факты про ваш бизнес знаете только вы.`
66
+ ? `Мы посмотрели ${host} глазами машины и написали, что увидели, без общих слов. Сначала делается первая неделя плана: три главных приоритета этого отчёта, потом остальные задачи по порядку. Для части задач нужны ваши факты: цифры, сроки, сведения о компании. Их знаете только вы, и исполнитель спросит их у вас.`
67
67
  : `Мы посмотрели ${host} глазами машины. Технически сайт в порядке, чинить нечего, и это честный вывод, а не комплимент. Рост дальше только через тексты: ответы на вопросы покупателей с цифрами и источниками.`,
68
68
  weekFill: 'Больше ничего срочного не нашли',
69
69
  monthFill: 'Тексты по вашим фактам: ответы в начале страниц, источники к цифрам, недостающие страницы',
@@ -75,8 +75,8 @@ const T = {
75
75
  unknownLang: 'not declared in the page code',
76
76
  sells: (d, p) => d ? `${d} (read from the homepage description)` : p ? `from the home page text: "${p}"; the home page has no search description` : 'the home page has no search description, and its text does not say it either',
77
77
  leadWhat: (h, n) => `We read ${n} page${n === 1 ? '' : 's'} of ${h} the way machines read them: the way a search engine and an AI open them.`,
78
- leadGood: (areas) => areas.length ? `What is done well: ${listEn(areas)}.` : 'None of the six areas reads as a strength yet.',
79
- leadBad: (area, n) => n ? `What holds it back: ${n} check${n === 1 ? '' : 's'} failed, and the hardest area is "${area}".` : `No check is failing, but the last points are missing, mostly in "${area}".`,
78
+ leadGood: (areas) => areas.length === 6 ? 'All six areas of the check are in good shape.' : areas.length ? `What is done well: ${listEn(areas)}.` : 'None of the six areas reads as a strength yet.',
79
+ leadBad: (area, n, partial = 0) => n ? `What holds it back: ${n} check${n === 1 ? '' : 's'} failed${area ? `, and the hardest area is "${area}"` : ''}.` : area ? `No check is failing, but the last points are missing, mostly in "${area}".` : `No check is failing${partial ? `; ${partial} check${partial === 1 ? ' is' : 's are'} only partly passed, listed below` : ''}.`,
80
80
  verdict: (openN, mech, people = 'buyers') => openN
81
81
  ? `${openN} check${openN === 1 ? ' is' : 's are'} not fully passed. ${mech} of them are mechanical, fixable without rewriting a word, which means quickly; the other ${openN - mech} need your facts: figures, sources, answers to what ${people} ask.`
82
82
  : `The point: nothing is broken. From here the site grows through text, not repair: answers to what ${people} ask, figures, and the sources behind them.`,
@@ -87,7 +87,7 @@ const T = {
87
87
  contactYes: 'getting in touch is possible from almost any page',
88
88
  contactNo: 'most pages offer no way to get in touch, so a visitor has to go looking',
89
89
  strengthNone: 'The checks found no strength in the text.',
90
- weakNone: 'The checks found no weakness in the text.',
90
+ weakNone: 'No weakness found in the text of the existing pages.',
91
91
  gapsNone: 'No gaps are visible across the sampled pages.',
92
92
  aeoWorksNone: 'Nothing that helps a page get quoted is working yet.',
93
93
  aeoBlocksNone: 'Nothing directly blocks a page from being quoted.',
@@ -99,7 +99,7 @@ const T = {
99
99
  geoCallout: (ok) => ok
100
100
  ? 'The fetchers that pull a page in order to quote it in an answer are allowed in. That is half the job: the rest is whether there is anything worth quoting.'
101
101
  : 'The fetchers that pull a page to quote it in an AI answer are blocked. While that is true, being quoted is physically impossible, whatever you write.',
102
- offpageListed: (profiles) => profiles.length ? `Profiles declared: ${profiles.join(', ')}.` : 'The site declares no company profile anywhere else.',
102
+ offpageListed: (profiles) => profiles.length ? '' : 'The site declares no company profile anywhere else.',
103
103
  offpageNote: 'We do not buy link data, so we print no number of links and no score built on one. What is checkable for free: whether the site says where else to find you, and whether that matches reality.',
104
104
  priceRow: 'Price visible',
105
105
  priceNo: 'no price on the pages we read',
@@ -124,12 +124,13 @@ const listRu = (a0) => { const a = a0.map(lowerMid); return a.length < 2 ? a[0]
124
124
  const listEn = (a) => (a.length < 2 ? a[0] || '' : sep(a) === '; ' ? a.join('; ') : `${a.slice(0, -1).join(', ')} and ${a[a.length - 1]}`);
125
125
 
126
126
  /** Проверки, которые Fix закрывает технически: ими меряется «сколько чинится быстро». */
127
- const MECHANICAL = new Set(['robots', 'robots-block', 'ai-search-access', 'sitemap', 'sitemap-foreign', 'sitemap-health', 'llms', 'title', 'description', 'h1', 'canonical', 'viewport', 'og', 'schema', 'faq-schema', 'org-schema', 'hreflang', 'xmlrpc', 'alt', 'h1-sample', 'dup-titles', 'utility', 'dates', 'trust-entity', 'trust-contact', 'trust-profiles', 'trust-about', 'trust-policies', 'conv-contact-path', 'conv-cta', 'conv-messenger', 'www', 'resources', 'conv-form-depth', 'conv-form-fields']);
127
+ const MECHANICAL = new Set(['ttfb', 'sections', 'robots', 'robots-block', 'ai-search-access', 'sitemap', 'sitemap-foreign', 'sitemap-health', 'llms', 'title', 'description', 'h1', 'canonical', 'viewport', 'og', 'schema', 'faq-schema', 'org-schema', 'hreflang', 'xmlrpc', 'alt', 'h1-sample', 'dup-titles', 'utility', 'dates', 'trust-entity', 'trust-contact', 'trust-policies', 'conv-contact-path', 'conv-cta', 'conv-messenger', 'www', 'resources', 'conv-form-depth', 'conv-form-fields']);
128
128
 
129
129
  import { vocabFor } from './niche.mjs';
130
130
  import { localiseChecks, AREAS_RU } from './i18n.mjs';
131
131
  import { FIX_ACTIONS } from './fix.mjs';
132
132
  import { snapshotFacts } from './facts.mjs';
133
+ import { isListPage } from './pagekind.mjs';
133
134
 
134
135
  const byId = (checks, id) => (checks || []).find((c) => c.id === id);
135
136
  const open = (c) => c && (c.status === 'warn' || c.status === 'bad');
@@ -150,7 +151,8 @@ export function draftNarrative(audit, opts = {}) {
150
151
  // Отчёт по-русски по английскому аудиту: названия проверок и областей переводим здесь же,
151
152
  // иначе владелец читает русский текст с английскими вставками и перестаёт нам верить.
152
153
  const needsRu = lang === 'ru' && a.meta?.lang !== 'ru';
153
- const checks = needsRu ? localiseChecks(a.checks || [], 'ru') : (a.checks || []);
154
+ // Русский отчёт всегда читает переведённые строки: «Сейчас: 1: t.me» в приоритетах был сырым значением (cimes.ru, 14.10.2026).
155
+ const checks = lang === 'ru' ? localiseChecks(a.checks || [], 'ru') : (a.checks || []);
154
156
  /*
155
157
  * Названия областей переводим по языку ОТЧЁТА, а не по языку исходного аудита.
156
158
  *
@@ -167,9 +169,15 @@ export function draftNarrative(audit, opts = {}) {
167
169
 
168
170
  const bad = checks.filter((c) => c.status === 'bad');
169
171
  const warn = checks.filter((c) => c.status === 'warn');
170
- const mechanical = [...bad, ...warn].filter((c) => MECHANICAL.has(c.id)).length;
172
+ // Карточка, адрес которой уже известен (listings), вписывается в разметку без фактов владельца: это техническая правка (cimes.ru, 15.10.2026).
173
+ const isMech = (c) => MECHANICAL.has(c.id) || (c.id === 'trust-profiles' && c.listings?.length > 0);
174
+ const mechanical = [...bad, ...warn].filter(isMech).length;
171
175
  const scores = Object.entries(a.scores || {}).filter(([, v]) => typeof v === 'number');
172
- const strong = scores.filter(([, v]) => v >= 8).map(([k]) => areaName(k));
176
+ // «Хорошо сделано» только для области без провалов и не больше чем с одной частичной проверкой: иначе её проблемы
177
+ // стоят в приоритетах, и абзац называет одну область и сильной, и главной бедой (cimes.ru, 14.10.2026).
178
+ const AREA_GROUPS = { 'SEO, technical': ['technical'], 'SEO, content and structure': ['onpage', 'content'], 'AEO, answers and snippets': ['aeo'], 'GEO, visibility in AI systems': ['geo'], 'Off-page and trust': ['offpage'], 'Conversion and UX': ['conversion'] };
179
+ const clean = (k) => { const g = AREA_GROUPS[k] || []; const own = checks.filter((c) => g.includes(c.group)); return !own.some((c) => c.status === 'bad') && own.filter((c) => c.status === 'warn').length <= 1; };
180
+ const strong = scores.filter(([k, v]) => v >= 8 && clean(k)).map(([k]) => areaName(k));
173
181
  const weakest = areaName(scores.slice().sort((x, y) => x[1] - y[1])[0]?.[0] || '');
174
182
 
175
183
  // Кто клиент. Имя это адрес сайта, предмет прочитан с главной и помечен как прочитанный.
@@ -180,7 +188,13 @@ export function draftNarrative(audit, opts = {}) {
180
188
  // Резюме.
181
189
  if (String(a.summary.lead).includes('{{')) {
182
190
  // Число это выборка, а не размер сайта: «сайт из 19 страниц» про магазин на сотни товаров неправда (12.10.2026).
183
- a.summary.lead = [t.leadWhat(host, sample.length), t.leadGood(strong), t.leadBad(weakest, bad.length)].join(' ');
191
+ // Слабейшая область, которая сама в «хорошо сделано» (оценка 8 и выше), слабой не называется: абзац противоречил себе (cimes.ru, 14.10.2026).
192
+ const weakestScore = scores.slice().sort((x, y) => x[1] - y[1])[0]?.[1];
193
+ // «Прочитали 20» и «ни на одной из 150 прочитанных» рядом без объяснения путали владельца (cimes.ru, 14.10.2026).
194
+ const readAll = Number(a.verification?.pagesRead) || 0;
195
+ const smTotal = Number((String(byId(checks, 'sitemap')?.value || '').match(/(\d+)/) || [])[1]) || 0;
196
+ const more = readAll > sample.length ? (lang === 'ru' ? `Кроме того, чтобы проверить каждое «нет на сайте», мы открыли ${readAll}${smTotal > readAll ? ` из ${smTotal}` : ''} ${plural(readAll, 'адрес', 'адреса', 'адресов').replace(/^\d+ /, '')} карты сайта (${sample.length} страниц выборки входят в это число).` : `We also read ${readAll} pages from the sitemap to check every "not on the site".`) : '';
197
+ a.summary.lead = [t.leadWhat(host, sample.length), more, t.leadGood(strong), t.leadBad(weakestScore >= 8 ? '' : weakest, bad.length, warn.length)].join(' ');
184
198
  }
185
199
  if (String(a.summary.verdict).includes('{{')) a.summary.verdict = t.verdict(bad.length + warn.length, mechanical, vocabFor(a, lang === 'ru' ? 'ru' : 'en').peopleGen);
186
200
  // Приоритет это не название проверки, а действие: владелец должен прочитать и понять, что делать.
@@ -204,10 +218,17 @@ export function draftNarrative(audit, opts = {}) {
204
218
  const ru = lang === 'ru'; const v = String(c.value || '');
205
219
  if (c.id === 'description' && /нет|no meta description|missing|none/i.test(v)) return ru ? 'Написать описание главной в 120-160 знаков' : 'Write a home page description of 120 to 160 characters';
206
220
  if (c.id === 'h1' && /нет|no H1/i.test(v)) return ru ? 'Добавить на главную один заголовок H1 о том, чем занимается компания' : 'Add one H1 to the home page saying what the business does';
221
+ // Какие страницы разбивать, называем: «разбить страницу» без адреса владельцу не говорит, о какой речь (cimes.ru, 14.10.2026).
222
+ if (c.id === 'answer-first') { const miss = (a.sample || []).filter((p) => p && p.title !== undefined && !p.answerFirst && !isListPage(p)).map((p) => { try { return new URL(p.url).pathname; } catch { return p.url; } }); if (miss.length) return ru ? `Поставить в начало страниц прямой ответ с цифрой, начиная с ${miss.slice(0, 3).join(', ')} (всего таких страниц с текстом в выборке ${miss.length}, список в файле задач; страницы-списки переписывать не нужно)` : `Put a direct answer with a figure at the top of the pages, starting with ${miss.slice(0, 3).join(', ')} (${miss.length} such sampled pages, listed in the tasks file)`; }
223
+ if (c.id === 'trust-profiles' && c.listings && c.listings.length) return ru ? `Указать в разметке карточку компании, которая уже показана на сайте: ${c.listings[0]}` : `Declare in the markup the company listing the site already shows: ${c.listings[0]}`;
224
+ if (c.id === 'trust-profiles' && c.messengersOnly) return ru ? 'Найти и указать в разметке настоящие профили компании на площадках' : 'Find and declare the company\'s real platform profiles in the markup';
225
+ if (c.id === 'trust-about' && c.status === 'bad') return ru ? 'Сделать страницу «О компании» и поставить ссылку на неё в меню или подвал' : 'Make an About page and link it from the menu or footer';
226
+ if (c.id === 'sections') { const long = (a.sample || []).filter((p) => p && p.title !== undefined && (p.words || 0) >= 600 && (p.h2Count || 0) < 3 && !isListPage(p)).slice(0, 3).map((p) => { try { return new URL(p.url).pathname; } catch { return p.url; } }); const few = Number((String(c.value).match(/(\d+) из|(\d+) of/) || [])[1] || 0); const rest = (a.sample || []).filter((p) => p && p.title !== undefined && (p.h2Count || 0) < 3 && !long.some((x) => p.url.endsWith(x))); const isList = isListPage; const nList = rest.filter(isList).length; const nShort = rest.length - nList; const why = few > long.length ? (ru ? ` (остальные ${nShort && nList ? 'короткие или это страницы-списки' : nList ? 'это страницы-списки' : 'короткие'}, их разбивать не нужно)` : ` (the others are ${nShort && nList ? 'short or list pages' : nList ? 'list pages' : 'short'})`) : ''; if (long.length) return ru ? `Разбить на разделы длинные страницы: ${long.join(', ')}${why}` : `Split the long pages into sections: ${long.join(', ')}${why}`; }
207
227
  if (c.id === 'alt') { const sm = (a.sample || []).filter((p) => p && p.title !== undefined); if (sm.length && sm.every((p) => p.imagesAltMissing === 0)) return ru ? 'Вписать подписи к фотографиям по их содержимому, значкам оформления оставить пустой alt' : 'Write alt text for the photos, leave decorative icons with an empty alt'; }
208
228
  return null;
209
229
  };
210
- const action = (c) => `${measuredAction(c) || actionText(c)}. ${lang === 'ru' ? 'Сейчас' : 'Now'}: ${c.value}`;
230
+ // Пункт приоритета это законченное предложение: с точкой в конце (cimes.ru, 15.10.2026).
231
+ const action = (c) => `${measuredAction(c) || actionText(c)}. ${lang === 'ru' ? 'Сейчас' : 'Now'}: ${String(c.value).replace(/[.\s]+$/, '')}.`;
211
232
  // В приоритеты идут только те находки, для которых есть действие. У части проверок в FIX_ACTIONS
212
233
  // вместо действия стоит довод против («это имеет смысл только тем, у кого правда есть API»), и
213
234
  // такая строка первым пунктом плана читается как отговорка, а не как задача.
@@ -221,11 +242,25 @@ export function draftNarrative(audit, opts = {}) {
221
242
  const c = top[i];
222
243
  return c ? action(c) : (lang === 'ru' ? 'Ничего срочного больше не нашли' : 'Nothing else is urgent');
223
244
  });
245
+ // Один список «что первым»: три приоритета, раздел «Что чинить первым» и первая неделя плана называют одно и то же.
246
+ // Раньше это были три разных списка, и владелец не понимал, с чего начинать (cimes.ru, 14.10.2026).
247
+ if (top.length) {
248
+ a.priorityIds = top.map((c) => c.id);
249
+ const fixWord = lang === 'ru' ? 'Исправить' : 'Fix';
250
+ a.critical = top.map((c) => ({ title: c.label, text: [c.value, c.comment ? `${String(c.comment)[0].toUpperCase()}${String(c.comment).slice(1)}` : ''].filter(Boolean).join('. '), level: c.status === 'bad' ? 'bad' : 'warn' }));
251
+ const w1 = (a.roadmap || [])[0];
252
+ if (w1 && /Неделя 1|Week 1/.test(String(w1.badge))) {
253
+ w1.items = top.map((c) => `${fixWord}: ${c.label}`);
254
+ const w2 = a.roadmap[1];
255
+ if (w2 && Array.isArray(w2.items)) w2.items = w2.items.filter((i) => !top.some((c) => String(i).includes(c.label)));
256
+ }
257
+ }
224
258
 
225
259
  // Обзор.
226
260
  a.overview.rows = (a.overview.rows || []).map(([k, v]) => {
227
261
  if (!String(v).includes('{{')) return [k, v];
228
- if (/language|язык/i.test(k)) return [k, a.meta?.language || t.unknownLang];
262
+ // «ru» владелец не читает: название языка словом (cimes.ru, 14.10.2026).
263
+ if (/language|язык/i.test(k)) { const code = String(a.meta?.language || '').toLowerCase().slice(0, 2); const RU_NAMES = { ru: 'русский', en: 'английский', uk: 'украинский', kk: 'казахский', de: 'немецкий', fr: 'французский', es: 'испанский', it: 'итальянский', th: 'тайский', tr: 'турецкий', ar: 'арабский', zh: 'китайский' }; const EN_NAMES = { ru: 'Russian', en: 'English', uk: 'Ukrainian', de: 'German', fr: 'French', es: 'Spanish', it: 'Italian', th: 'Thai', tr: 'Turkish', ar: 'Arabic', zh: 'Chinese' }; return [k, code ? ((lang === 'ru' ? RU_NAMES : EN_NAMES)[code] || a.meta.language) : t.unknownLang]; }
229
264
  if (/sold|продаётся|продается|чем занимается/i.test(k)) return [k, t.sells(desc, String(home.offerSentence || home.firstPara || '').trim().slice(0, 220).replace(/[\s.,;:]+$/, ''))];
230
265
  return [k, t.notMeasured];
231
266
  });
@@ -237,13 +272,21 @@ export function draftNarrative(audit, opts = {}) {
237
272
 
238
273
  // Контент, AEO, GEO, ссылки, конверсия: строки собираются из своих же групп проверок.
239
274
  const group = (g) => checks.filter((c) => c.group === g);
240
- const fillList = (arr, source, empty) => (arr || []).map((x, i) => {
241
- if (!String(x).includes('{{')) return x;
242
- const c = source[i];
243
- return c ? `${c.label}: ${c.value}` : empty;
244
- });
275
+ // Строк в шаблоне меньше, чем найденного: остальное дописываем (до четырёх), иначе «Что мешает» молчит о второй проблеме (cimes.ru, 14.10.2026).
276
+ const fillList = (arr, source, empty) => {
277
+ const slots = (arr || []).filter((x) => String(x).includes('{{')).length;
278
+ const out = (arr || []).map((x, i) => {
279
+ if (!String(x).includes('{{')) return x;
280
+ const c = source[i];
281
+ return c ? `${c.label}: ${c.value}` : empty;
282
+ });
283
+ if (slots && source.length > slots) out.push(...source.slice(slots, 4).map((c) => `${c.label}: ${c.value}`));
284
+ return out;
285
+ };
245
286
  a.content.strengths = fillList(a.content.strengths, group('content').filter((c) => c.status === 'ok'), t.strengthNone);
246
- a.content.weaknesses = fillList(a.content.weaknesses, group('content').filter(open), t.weakNone);
287
+ // Слабые места текстов могут быть в разделе ответов ИИ (первый абзац, разделы): тогда «не нашли» здесь противоречит ему (cimes.ru, 14.10.2026).
288
+ const aeoOpen = group('aeo').filter(open);
289
+ a.content.weaknesses = fillList(a.content.weaknesses, group('content').filter(open), aeoOpen.length ? (lang === 'ru' ? `Слабые места текстов относятся к ответам ИИ и описаны ниже, в разделе «Ответные системы: AEO»: ${aeoOpen.slice(0, 3).map((c) => c.label.toLowerCase()).join(', ')}.` : `The text weaknesses concern AI answers and are covered in that section below: ${aeoOpen.slice(0, 3).map((c) => c.label.toLowerCase()).join(', ')}.`) : t.weakNone);
247
290
  // «Структурные дыры» это страницы, которых нет, а не проваленные проверки текста (14.10.2026: туда попадали источники цифр).
248
291
  {
249
292
  const ru = lang === 'ru'; const miss = [];
@@ -258,7 +301,8 @@ export function draftNarrative(audit, opts = {}) {
258
301
  a.aeo.works = fillList(a.aeo.works, group('aeo').filter((c) => c.status === 'ok'), t.aeoWorksNone);
259
302
  a.aeo.blocks = fillList(a.aeo.blocks, group('aeo').filter(open), t.aeoBlocksNone);
260
303
  if (String(a.aeo.recommendation).includes('{{')) {
261
- const thinnest = sample.filter((p) => p.answerFirst === false).sort((x, y) => (y.words || 0) - (x.words || 0))[0];
304
+ // Та же первая страница, что в приоритете и в задаче: три места советовали начать с разных (cimes.ru, 15.10.2026).
305
+ const thinnest = sample.filter((p) => p.answerFirst === false && !isListPage(p))[0] || sample.filter((p) => p.answerFirst === false)[0];
262
306
  a.aeo.recommendation = t.aeoRec(thinnest ? thinnest.url : '');
263
307
  }
264
308
 
@@ -276,10 +320,13 @@ export function draftNarrative(audit, opts = {}) {
276
320
  }
277
321
 
278
322
  const profilesCheck = byId(checks, 'trust-profiles');
279
- const profiles = profilesCheck && profilesCheck.status === 'ok' ? String(profilesCheck.value).split(/[,:]/).slice(1).map((s) => s.trim()).filter(Boolean) : [];
323
+ // Профиль в разметке есть и при статусе «частично» (один профиль): «ни одного» рядом с «1: t.me» было противоречием (cimes.ru, 14.10.2026).
324
+ const profiles = profilesCheck && /^(\d+:|указан|\d+ profiles?:)/.test(String(profilesCheck.value)) ? String(profilesCheck.value).split(/[,:]/).slice(1).map((s) => s.trim()).filter(Boolean) : [];
280
325
  // Ссылки на профили на самих страницах (второй взгляд их видит) тоже профиль, просто не в разметке (14.10.2026).
281
326
  const linkedOnly = profilesCheck && /ссылк\S* на сайте есть|ссылка на канал|pages link to/i.test(String(profilesCheck.value));
282
- a.offpage.listed = (a.offpage.listed || []).map((x) => String(x).includes('{{') ? (linkedOnly ? (lang === 'ru' ? 'В разметке профили не заявлены, но на сайте есть ссылки на свои профили: их стоит вписать в sameAs.' : 'The markup declares no profiles, but the pages link to the company profiles: put them into sameAs.') : t.offpageListed(profiles)) : x);
327
+ // Карточка показана на сайте, но не в разметке, или в разметке одни мессенджеры: «ни одного профиля» было бы неправдой (cimes.ru, 15.10.2026).
328
+ const listedNote = profilesCheck?.listings?.length ? (lang === 'ru' ? `Карточка компании ${profilesCheck.listings[0]} показана на сайте, но в разметке не указана.` : `The company listing ${profilesCheck.listings[0]} is shown on the site but not declared in the markup.`) : profilesCheck?.messengersOnly?.length ? (lang === 'ru' ? 'В разметке указаны только мессенджеры, профилей на площадках нет.' : 'The markup lists only messengers, no platform profiles.') : '';
329
+ a.offpage.listed = (a.offpage.listed || []).map((x) => String(x).includes('{{') ? (linkedOnly ? (lang === 'ru' ? 'В разметке профили не заявлены, но на сайте есть ссылки на свои профили: их стоит вписать в sameAs.' : 'The markup declares no profiles, but the pages link to the company profiles: put them into sameAs.') : (listedNote || t.offpageListed(profiles))) : x);
283
330
  if (String(a.offpage.note).includes('{{')) a.offpage.note = t.offpageNote;
284
331
 
285
332
  a.conversion.rows = (a.conversion.rows || []).map(([label, status]) => {
@@ -303,15 +350,15 @@ export function draftNarrative(audit, opts = {}) {
303
350
  const labelToCheck = new Map(checks.map((c) => [c.label, c]));
304
351
  const englishLabel = new Map((a.checks || []).map((c, i) => [c.label, checks[i]]));
305
352
  const retitle = (item) => {
306
- const m = /^Fix:\s*(.+)$/.exec(String(item));
353
+ const m = /^(?:Fix|Исправить):\s*(.+)$/.exec(String(item));
307
354
  if (!m) return item;
308
355
  const c = labelToCheck.get(m[1]) || englishLabel.get(m[1]);
309
- return c ? actionText(c) : item;
356
+ return c ? (measuredAction(c) || actionText(c)) : item;
310
357
  };
311
358
  a.roadmap = (a.roadmap || []).map((phase, pi) => ({
312
359
  ...phase,
313
360
  items: (phase.items || []).map((item, ii) => {
314
- if (!String(item).includes('{{')) return needsRu ? retitle(item) : item;
361
+ if (!String(item).includes('{{')) return retitle(item);
315
362
  const pool = pi === 0 ? mech : pi === 1 ? textWork : [];
316
363
  const c = pool[ii];
317
364
  if (c) return actionText(c);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Вид страницы, одно правило на весь движок (15.10.2026, cimes.ru). Страница-список (CollectionPage, ItemList)
3
+ * разводит по подразделам: переписывать её начало и разбивать на разделы не нужно. Раньше задача про разделы
4
+ * признавала /services/integration/ списком, а задача про первый абзац тут же требовала его переписать.
5
+ */
6
+ export const isListPage = (p) => (p?.schemaTypes || []).some((t) => /^(CollectionPage|ItemList)$/.test(t));
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Проверка оформления отчёта кодом перед сборкой PDF (14.10.2026, задание «проверка глазами»).
3
+ *
4
+ * Простым языком. Проверка фактов ловит неправду, но не ловит сырой вид: пустой раздел плана,
5
+ * «Сейчас: 1: t.me», фразу, оборванную на предлоге, «Норма» при 1 из 20, «Тонкие страницы» в сильных
6
+ * сторонах. Отчёт cimes.ru ушёл бы клиенту таким. Здесь восемь правил, каждое смотрит готовый текст
7
+ * отчёта и задач. Хоть одна находка: отчёт не собирается и не уходит, причина пишется словами.
8
+ * Модель не нужна, денег это не стоит.
9
+ */
10
+ import { statementsOf } from './review.mjs';
11
+ import { agentPrompts } from './prompts.mjs';
12
+ import { localiseChecks } from './i18n.mjs';
13
+
14
+ /** Пороги в одном месте: «Норма» у строки «X из N» невозможна, если X меньше половины N и меньше трёх. */
15
+ export const STATUS_OK_SHARE = 0.5;
16
+ export const STATUS_OK_MIN = 3;
17
+
18
+ /** Строки, где «X из N» считает страницы, на которых проблема: X это сколько страниц должна назвать задача. */
19
+ export const PROBLEM_SHARE = new Set(['sections', 'answer-first']);
20
+ /** Строки, где «X из N» считает страницы, на которых всё хорошо: чем больше X, тем лучше. */
21
+ export const GOOD_SHARE = new Set(['dates', 'conv-cta', 'schema-all', 'sources', 'sitemap-health', 'title-all', 'description-all', 'canonical-all', 'og-all', 'faq-visible']);
22
+
23
+ const SERVICE = [
24
+ [/(^|[\s:(])\d+: [a-z0-9-]+\.[a-z]{2,}(?=[\s,.;)]|$)/i, 'служебное значение вида «1: t.me»'],
25
+ [/\bundefined\b/, '«undefined»'],
26
+ [/(?<!\/dev\/)\bnull\b/, '«null»'],
27
+ [/\bNaN\b/, '«NaN»'],
28
+ [/\[object /, '«[object …]»'],
29
+ [/\{\{|\}\}|(^|\s)[{}](\s|$)/, 'фигурная скобка шаблона'],
30
+ [/\b(chk|task|sum|sub|ov|road|cont|aeo|geo|conv|off):[a-z][\w-]*/, 'голый ключ вида chk:…'],
31
+ ];
32
+ // Конец на предлоге или союзе, либо «на 1» без существительного («та же дата видна на самой странице на 1», cimes.ru).
33
+ const DANGLING = /(?:^|\s)(на|в|во|из|с|со|к|ко|по|о|об|от|до|для|при|про|у|и|или|а|но|что|of|on|in|at|to|for|and|or|the|with|from|by)$|(?:^|\s)(?:на|у|по|on) \d+\.?$/i;
34
+ const NEGATIVE_LABEL = /тонк|короткие|ошибк|битые|битых|пуст|дубл|нет |не найден|thin|broken|missing|duplicate|empty|error/i;
35
+ const QUOTED = /«[^»]*»|"[^"]*"|“[^”]*”/g;
36
+ /** Поля, где стоит текст с сайта клиента: тире там его, не наше. */
37
+ const SITE_TEXT = (id) => id === 'sub:subject' || /^ov:row:/.test(id) || id === 'chk:schema:value';
38
+
39
+ const pagePaths = (t) => [...new Set((String(t).match(/(?<![\w.])\/[\w%.-]+(?:\/[\w%.-]+)*\/?/g) || []).filter((p) => p.length > 1))];
40
+
41
+ /**
42
+ * @returns {{ ok: boolean, problems: { rule: number, id: string, why: string, text: string }[] }}
43
+ */
44
+ export function presentationCheck(audit, { lang = audit.meta?.lang || 'en' } = {}) {
45
+ const ru = lang === 'ru';
46
+ const problems = [];
47
+ const add = (rule, id, why, text = '') => problems.push({ rule, id, why, text: String(text).slice(0, 200) });
48
+ const st = statementsOf(audit, { lang });
49
+ const tasks = agentPrompts(audit, { lang });
50
+
51
+ // 1. Пустой раздел или заголовок без строк.
52
+ for (const [i, ph] of (audit.roadmap || []).entries()) if (!(ph.items || []).filter((x) => String(x).trim()).length) add(1, `road:${i}`, `пустой раздел плана «${ph.badge}»`);
53
+ if (!(audit.summary?.priorities || []).filter((x) => String(x).trim()).length) add(1, 'sum:prio', 'нет ни одного приоритета');
54
+ for (const t of tasks) if (!/\S/.test(String(t.text).replace(/\*\*[^*]+\*\*/g, '').replace(/[#\s]/g, ''))) add(1, `task:${t.id}`, 'пустая задача');
55
+
56
+ // 2. Служебные значения. 3. Оборванная фраза. 8. Тире в наших фразах.
57
+ const texts = [...st.map((s) => ({ id: s.id, text: s.text })), ...tasks.map((t) => ({ id: `task:${t.id}`, text: t.text }))];
58
+ for (const { id, text } of texts) {
59
+ // Команды для разработчика (curl, текст в обратных кавычках) не наш текст для владельца: «/dev/null» там на месте.
60
+ const own = String(text).replace(/`[^`]*`/g, ' ').replace(/\bcurl\b[^)\n]*/g, ' ').replace(QUOTED, ' ');
61
+ for (const [re, why] of SERVICE) if (re.test(own)) add(2, id, why, text);
62
+ for (const line of String(text).split('\n')) { const l = line.replace(/\*\*/g, '').trim(); if (l && DANGLING.test(l)) add(3, id, 'фраза оборвана на предлоге или союзе', l); }
63
+ if (!SITE_TEXT(id) && /[—–]/.test(own)) add(8, id, 'длинное или среднее тире в нашем тексте', text);
64
+ }
65
+
66
+ // 4. Статус противоречит числу. 7. Число в задаче не совпадает с отчётом.
67
+ const shown = ru ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
68
+ for (const c of shown) {
69
+ if (!GOOD_SHARE.has(c.id) && !PROBLEM_SHARE.has(c.id)) continue;
70
+ const m = String(c.value || '').match(/(\d+) (?:из|of) (\d+)/);
71
+ if (!m) continue;
72
+ let [x, n] = [Number(m[1]), Number(m[2])];
73
+ // Английская строка «15 of 20 carry three or more H2» считает хорошие страницы: проблемных n - x.
74
+ if (PROBLEM_SHARE.has(c.id) && /three or more|три и более|open with|есть на всех/i.test(String(c.value))) x = n - x;
75
+ if (GOOD_SHARE.has(c.id) && c.status === 'ok' && n && x / n < STATUS_OK_SHARE && x < STATUS_OK_MIN) add(4, `chk:${c.id}`, `«Норма» при ${x} из ${n}`, `${c.label}: ${c.value}`);
76
+ const t = tasks.find((k) => k.id === c.id);
77
+ if (t) {
78
+ const listed = pagePaths(String(t.text).split(/\*\*(?:Как проверить|How to check):\*\*/)[0].replace(/\*\*(?:Сейчас|Now):\*\*[^\n]*/, ''));
79
+ const need = PROBLEM_SHARE.has(c.id) ? x : n - x;
80
+ if (listed.length && listed.length < Math.min(need, 5) && !/вошли только|вошли страницы|only the long|остальные|the others|есть только на|is on only/i.test(t.text)) add(7, `task:${c.id}`, `в отчёте ${need} страниц, в задаче названо ${listed.length}, правило отбора не объяснено`, t.text);
81
+ }
82
+ }
83
+
84
+ // 5. Отрицательное название в сильных сторонах.
85
+ for (const [i, x] of (audit.content?.strengths || []).entries()) { const label = String(x).split(':')[0]; if (NEGATIVE_LABEL.test(label)) add(5, `cont:str:${i}`, `в сильных сторонах название с отрицательным смыслом: «${label}»`, x); }
86
+
87
+ // 6. «Не измеряли» там, где данные есть.
88
+ const ov = audit.overview?.rows || [];
89
+ const hasCheck = (id) => (audit.checks || []).some((c) => c.id === id && c.value);
90
+ for (const [i, [k, v]] of ov.entries()) {
91
+ if (!/не измеряли|not measured/i.test(String(v))) continue;
92
+ if ((/движок|cms|engine/i.test(k) && hasCheck('cms')) || (/карт|sitemap/i.test(k) && hasCheck('sitemap')) || (/язык|language/i.test(k) && audit.meta?.language)) add(6, `ov:row:${i}`, `«не измеряли» в строке «${k}», хотя данные собраны`);
93
+ }
94
+ return { ok: problems.length === 0, problems };
95
+ }
package/src/prompts.mjs CHANGED
@@ -14,6 +14,7 @@
14
14
  * Заданий с собственным текстом столько, сколько проверок мы умеем чинить осмысленно. Для
15
15
  * остальных собирается общее задание из описания работы: оно короче, но не врёт.
16
16
  */
17
+ import { isListPage } from './pagekind.mjs';
17
18
  import { FIX_ACTIONS } from './fix.mjs';
18
19
  import { localiseChecks } from './i18n.mjs';
19
20
  import { vocabFor } from './niche.mjs';
@@ -222,7 +223,7 @@ const P = {
222
223
  en: { task: 'Ask the owner where the business really has profiles: social networks, directories, maps, industry sites. Put those addresses in the sameAs field of the organisation markup and link them from the footer. Do not add profiles that do not exist or are abandoned.', verify: 'The organisation JSON-LD has sameAs with at least two addresses, and each opens a profile of this very business.' },
223
224
  },
224
225
  'trust-about': {
225
- ru: { task: 'Сделай страницу «О компании» (или «Команда») и поставь на неё ссылку в меню или подвал, чтобы она была видна со всех страниц. На странице: кто стоит за сайтом, чем занимается, с какого года, где находится, как связаться. Всё только со слов владельца или из того, что уже опубликовано; если страница уже есть, просто добавь ссылку.', verify: 'Со всех проверенных страниц есть ссылка на страницу о компании, и она открывается с кодом 200.' },
226
+ ru: { task: 'Сделай страницу «О компании» (или «Команда») и поставь на неё ссылку в меню или подвал, чтобы она была видна со всех страниц. На странице: кто стоит за сайтом, чем занимается, с какого года, где находится, как связаться. Всё только со слов владельца или из того, что уже опубликовано; если страница уже есть, просто добавь ссылку.', verify: 'Ссылка на страницу о компании стоит в меню или подвале каждой страницы сайта, и страница открывается с кодом 200.' },
226
227
  en: { task: 'Make an About (or Team) page and link it from the menu or footer so it is reachable from every page. On it: who is behind the site, what they do, since when, where, how to get in touch. Use only what the owner tells you or what is already published; if the page exists, just add the link.', verify: 'Every sampled page links to the about page, and it opens with a 200.' },
227
228
  },
228
229
  'trust-policies': {
@@ -275,9 +276,19 @@ const PF = {
275
276
  title: ({ ru, v }) => (ru
276
277
  ? { task: 'Перепиши заголовок главной (тег title): до 60 знаков, словами, которыми ищут ' + v.people + ', что это за компания и чем она занимается, название компании в конце. Не повторяй слова и не пиши список ключевых слов через запятую.', verify: 'Заголовок главной от 30 до 60 знаков, в нём названо, чем занимается компания, и он отличается от заголовков других страниц.' }
277
278
  : { task: 'Rewrite the home page title: up to 60 characters, in the words ' + v.people + ' search with, saying what the business is and does, company name at the end. No repeated words, no keyword lists.', verify: 'The home page title is 30 to 60 characters, says what the business does, and differs from other page titles.' }),
278
- dates: ({ ru }) => (ru
279
- ? { task: 'Сверь даты публикации в разметке (datePublished) с настоящими датами материалов: если тема сортирует записи по этой дате, сначала переключи сортировку на настоящую дату, потом исправь даты в разметке. Затем выведи дату публикации видимой строкой на странице каждого материала.', verify: 'На странице каждого материала видна дата публикации, и она совпадает с datePublished в его разметке.' }
280
- : { 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.' }),
279
+ // Дат мало: называем, где они есть, и на каких страницах их ставить; «сверь даты» исполнителю ничего не говорит (cimes.ru, 14.10.2026).
280
+ dates: ({ audit, ru }) => {
281
+ const sm = sampleOf(audit); const dated = sm.filter((p) => p.dates && (p.dates.published || p.dates.modified));
282
+ const c = (audit?.checks || []).find((x) => x.id === 'dates');
283
+ if (c && !/precede/.test(String(c.value)) && dated.length < sm.length / 2) {
284
+ const list = dated.slice(0, 5).map((p) => pathOf(p.url)).join(', ');
285
+ return ru
286
+ ? { task: `Дата публикации в разметке (datePublished) есть только на ${dated.length} из ${sm.length} страниц выборки${list ? `: ${list}` : ''}. Добавь её и видимую строку «Опубликовано: число» на страницы, у которых правда есть дата: статьи блога, новости, кейсы портфолио. Страницам услуг и контактов дата не нужна, не ставь её туда.`, verify: 'На статьях, новостях и кейсах в разметке есть datePublished, и та же дата видна на самой странице.' }
287
+ : { task: `A published date in the markup (datePublished) is on only ${dated.length} of ${sm.length} sampled pages${list ? `: ${list}` : ''}. Add it, with a visible "Published: date" line, to pages that really have a date: blog posts, news, portfolio cases. Service and contact pages need none.`, verify: 'Posts, news and cases carry datePublished in the markup, and the same date is visible on the page.' };
288
+ }
289
+ return datesGeneric({ ru });
290
+ },
291
+
281
292
  'trust-entity': ({ ru }) => (ru
282
293
  ? { task: 'После того как появится разметка организации (задача про неё выше), допиши в неё адрес: город и страну (PostalAddress с addressLocality и addressCountry). Адрес головного офиса спроси у владельца, если на сайте его нет; адреса филиалов бери со страниц сайта.', verify: 'В разметке организации есть город и страна, и они совпадают с адресом, который подтвердил владелец.' }
283
294
  : { task: 'Once the organisation markup exists (task above), add the address: city and country (PostalAddress with addressLocality and addressCountry). Ask the owner for the head office address if the site does not show it.', verify: 'The organisation markup carries city and country, matching the address the owner confirmed.' }),
@@ -326,18 +337,40 @@ const PF = {
326
337
  'faq-schema': ({ ru, v }) => (ru
327
338
  ? { task: `Собери с владельцем 5-7 частых вопросов ${v.peopleGen} с ответами, опубликуй их видимым блоком на страницах главных услуг и только потом разметь схемой FAQPage. Текст в разметке должен слово в слово совпадать с видимым. Вопросов, которых нет на странице, в разметку не добавляй.`, verify: 'На выбранных страницах есть разметка FAQPage минимум с пятью вопросами, и каждый вопрос и ответ из неё дословно виден на странице.' }
328
339
  : { task: `Collect 5 to 7 frequent questions from ${v.peopleGen} with the owner, publish them as a visible block on the main service pages, and only then mark them up with FAQPage. The markup text must match the visible text word for word.`, verify: 'The chosen pages carry FAQPage markup with at least five questions, each question and answer visible on the page word for word.' }),
340
+ // В разметке только мессенджеры: задача не «впиши то, что уже вписано», а найти настоящие профили (cimes.ru, 15.10.2026).
341
+ 'trust-profiles-messengers': ({ check, ru }) => {
342
+ // Карточка на площадке на сайте уже есть (виджет рейтинга или ссылка), а в разметке её нет: начинаем с неё (cimes.ru, 15.10.2026).
343
+ const ls = check?.listings;
344
+ if (ls && ls.length) return ru
345
+ ? { task: `Впиши в поле sameAs разметки организации адрес карточки компании: ${ls.join(', ')}. Она уже показана на сайте (рейтинг или ссылка в подвале), но в разметке не указана. Потом спроси владельца, есть ли другие настоящие карточки (2ГИС, Google Карты, отраслевые каталоги, соцсети компании), и добавь только существующие. Мессенджеры профилями не считаются.`, verify: `В sameAs разметки организации есть ${ls[0]}, и адрес открывает карточку этой компании.` }
346
+ : { task: `Add the company listing to the sameAs field of the organisation markup: ${ls.join(', ')}. The site already shows it (rating badge or footer link), but the markup does not declare it. Then ask the owner about other real listings and add only those that exist. Messengers are not profiles.`, verify: `The sameAs of the organisation markup contains ${ls[0]}, and it opens this company's listing.` };
347
+ const m = check?.messengersOnly;
348
+ if (!m || !m.length) return null;
349
+ const list = m.join(', ');
350
+ return ru
351
+ ? { task: `В разметке организации (sameAs) сейчас указаны только мессенджеры: ${list}. Это способы написать, а не профили компании на площадках. Спроси владельца, где у компании есть настоящие карточки: Яндекс Карты или Яндекс Бизнес, 2ГИС, Google Карты, отраслевые каталоги и рейтинги, соцсети компании. Впиши в sameAs только существующие адреса и поставь ссылки на них в подвал. Если таких карточек нет, задачу не делай и не выдумывай профили.`, verify: 'В sameAs разметки организации есть хотя бы одна ссылка на карточку компании на площадке (карты, каталог, соцсеть), кроме мессенджеров, и она открывает профиль этой компании.' }
352
+ : { task: `The organisation markup (sameAs) lists only messengers now: ${list}. Those are ways to write, not company profiles on platforms. Ask the owner where the business really has listings: maps, directories and ratings, its own social accounts. Put only the ones that exist into sameAs and link them from the footer. If there are none, skip this task and invent nothing.`, verify: 'The sameAs of the organisation markup has at least one link to a company listing on a platform (maps, directory, social network) besides messengers, and it opens this company\'s profile.' };
353
+ },
329
354
  sections: ({ audit, ru }) => {
330
355
  // Страница-список (CollectionPage, ItemList) разводит по подразделам, разбивать её на разделы незачем (cimes.ru, 14.10.2026).
331
- const long = sampleOf(audit).filter((p) => (p.words || 0) >= 600 && (p.h2Count || 0) < 3 && !(p.schemaTypes || []).some((t) => /^(CollectionPage|ItemList)$/.test(t)));
356
+ const long = sampleOf(audit).filter((p) => (p.words || 0) >= 600 && (p.h2Count || 0) < 3 && !isListPage(p));
332
357
  if (!long.length) return null;
333
358
  const list = long.slice(0, 5).map((p) => pathOf(p.url)).join(', ');
359
+ // В отчёте «меньше трёх H2» считается по всем страницам выборки, а в задачу идут только длинные: правило отбора пишем в тексте (cimes.ru, 14.10.2026).
360
+ const few = sampleOf(audit).filter((p) => (p.h2Count || 0) < 3);
361
+ const rest = few.filter((p) => !long.includes(p));
362
+ const isList = isListPage;
363
+ const shortOnes = rest.filter((p) => !isList(p)); const lists = rest.filter(isList);
364
+ const why = rest.length ? (ru
365
+ ? ` Из ${few.length} страниц выборки, где меньше трёх H2, сюда вошли страницы с обычным текстом от 600 слов.${shortOnes.length ? ` Короткие (${shortOnes.slice(0, 5).map((p) => pathOf(p.url)).join(', ')}) разбивать не нужно.` : ''}${lists.length ? ` Страницы-списки подразделов (${lists.slice(0, 5).map((p) => pathOf(p.url)).join(', ')}) ведут на другие страницы, разбивать их на разделы не нужно.` : ''}`
366
+ : ` Of the ${few.length} sampled pages with fewer than three H2s only the long ones, 600 words and more, are listed. The others (${rest.slice(0, 5).map((p) => pathOf(p.url)).join(', ')}) are short or are list pages: leave them.`) : '';
334
367
  // Подзаголовки уже есть, но размечены H3: задача не «придумать H2», а поднять существующие (14.10.2026).
335
368
  if (long.every((p) => (p.h3Count || 0) >= 2)) return ru
336
- ? { task: `На ${long.length === 1 ? 'странице' : 'страницах'} ${list} подзаголовки уже есть, но размечены как H3. Сделай существующие смысловые подзаголовки H2; новые добавляй, только если без них раздел не читается. Текст не дописывай.`, verify: `${long.length === 1 ? 'На странице' : 'На страницах'} ${list} не меньше трёх заголовков H2 по смыслу текста.` }
337
- : { task: `On ${list} the subheadings already exist but are marked up as H3. Make the existing meaningful subheadings H2; add new ones only where a section cannot be read without them. Do not add text.`, verify: `${list} carry at least three H2s that follow the text.` };
369
+ ? { task: `На ${long.length === 1 ? 'странице' : 'страницах'} ${list} подзаголовки уже есть, но размечены как H3. Сделай существующие смысловые подзаголовки H2; новые добавляй, только если без них раздел не читается. Текст не дописывай.${why}`, verify: `${long.length === 1 ? 'На странице' : 'На страницах'} ${list} не меньше трёх заголовков H2 по смыслу текста.` }
370
+ : { task: `On ${list} the subheadings already exist but are marked up as H3. Make the existing meaningful subheadings H2; add new ones only where a section cannot be read without them. Do not add text.${why}`, verify: `${list} carry at least three H2s that follow the text.` };
338
371
  return ru
339
- ? { task: `Разбей на разделы ${long.length === 1 ? 'длинную страницу, где' : 'длинные страницы, где'} сейчас меньше трёх заголовков H2: ${list}. Заголовки H2 по смыслу текста. Ничего не дописывай, только структурируй.`, verify: `${long.length === 1 ? 'На странице' : 'На страницах'} ${list} не меньше трёх заголовков H2 по смыслу текста.` }
340
- : { task: `Split into sections the long pages that now have fewer than three H2s: ${list}. H2s that follow the text. Add nothing, only structure.`, verify: `The pages ${list} carry at least three H2s that follow the text.` };
372
+ ? { task: `Разбей на разделы ${long.length === 1 ? 'длинную страницу, где' : 'длинные страницы, где'} сейчас меньше трёх заголовков H2: ${list}. Заголовки H2 по смыслу текста. Ничего не дописывай, только структурируй.${why}`, verify: `${long.length === 1 ? 'На странице' : 'На страницах'} ${list} не меньше трёх заголовков H2 по смыслу текста.` }
373
+ : { task: `Split into sections the long pages that now have fewer than three H2s: ${list}. H2s that follow the text. Add nothing, only structure.${why}`, verify: `The pages ${list} carry at least three H2s that follow the text.` };
341
374
  },
342
375
  'conv-cta': ({ ru, v }) => (ru
343
376
  ? { task: `Поставь на первый экран каждой главной страницы одно понятное действие, которое на сайте уже есть: ${v.cta}. Одно действие на экран. Не выдумывай новых обещаний и сроков.`, verify: 'На каждой такой странице в верхних 15 процентах высоты, в основном блоке страницы, а не в шапке, есть кнопка или ссылка с этим действием.' }
@@ -359,7 +392,7 @@ const PF = {
359
392
  ? { task: 'Выключи архивы авторов в плагине SEO (в Yoast: SEO, Отображение в поисковой выдаче, вкладка Архивы; в английском интерфейсе Search Appearance, Archives), тогда страница автора уйдёт из карты сайта. Если имя автора выглядит как логин (например, admin), смени отображаемое имя в профиле пользователя WordPress на название компании.', verify: 'В карте сайта нет страниц авторов, и в разметке страниц нет имени-логина.' }
360
393
  : { task: 'Switch off author archives in the SEO plugin (Yoast: Search Appearance, Archives), so the author page leaves the sitemap. If the author name looks like a login (admin, say), change the display name in the WordPress user profile to the company name.', verify: 'The sitemap has no author pages, and the page markup carries no login-like name.' }) : null),
361
394
  ttfb: ({ ru }) => (ru
362
- ? { task: 'Сначала сделай три замера из страны, где живут твои посетители. Если ответ больше 0,8 секунды (порог, который Google считает хорошим), включи кеширование готовых страниц: плагин кеша, кеш хостинга или CDN. Менять хостинг только из-за нашего замера не нужно: мы меряем не из твоей страны.', verify: 'Три замера подряд из страны посетителей (curl -o /dev/null -w "%{time_starttransfer}" адрес главной) показывают не больше 0,8 секунды.' }
395
+ ? { task: 'Сначала сделай три замера из страны, где живут посетители сайта. Если ответ больше 0,8 секунды (порог, который Google считает хорошим), включи кеширование готовых страниц: плагин кеша, кеш хостинга или CDN. Менять хостинг только из-за нашего замера не нужно: мы мерили с сервера в Германии, а не из страны посетителей сайта.', verify: 'Три замера подряд из страны посетителей (curl -o /dev/null -w "%{time_starttransfer}" адрес главной) показывают не больше 0,8 секунды.' }
363
396
  : { task: 'First take three measurements from the country your visitors live in. If the answer takes over 0.8 seconds (the level Google calls good), turn on page caching: a cache plugin, host cache or a CDN. Do not move host on our measurement alone: we measure from elsewhere.', verify: 'Three runs in a row from your visitors\' country (curl -o /dev/null -w "%{time_starttransfer}" home URL) show 0.8 seconds or less.' }),
364
397
  thin: ({ audit, ru, v }) => {
365
398
  const sm = sampleOf(audit); const home = sm[0];
@@ -388,15 +421,17 @@ const PF = {
388
421
  : null) || (ru
389
422
  ? { task: 'Политику конфиденциальности и согласие на обработку персональных данных готовит владелец или юрист: текст не сочиняй. Когда текст будет, опубликуй его отдельной страницей, поставь ссылку в подвал и добавь к каждой форме, которая собирает имя, телефон или почту, галочку согласия со ссылкой на политику. Если правила или условия услуг лежат внутри другой страницы, вынеси их на отдельную и тоже поставь ссылку в подвал.', verify: 'В подвале каждой страницы есть ссылка на политику, она открывается с кодом 200, и у каждой формы есть галочка согласия.' }
390
423
  : { task: 'The owner or a lawyer writes the privacy policy and terms: do not draft legal text. Once it exists, publish it as its own page, link it from the footer, and add a consent checkbox linking to it on every form that collects a name, phone or email.', verify: 'Every page footer links the policy, it answers 200, and every form has a consent checkbox.' }),
391
- 'trust-profiles': ({ check, ru }) => (/ссылки на сайте есть|pages link to/i.test(String(check.value)) ? (ru
424
+ 'trust-profiles': (ctx) => PF['trust-profiles-messengers'](ctx) || (/ссылки на сайте есть|pages link to/i.test(String(ctx.check.value)) ? (ctx.ru
392
425
  ? { task: 'Профили, на которые сайт уже ссылается, впиши в поле sameAs разметки организации. Спроси владельца, есть ли другие настоящие профили (карты, отраслевые каталоги, соцсети), и добавь только подтверждённые.', verify: 'В разметке организации есть sameAs со ссылками на профили, которые уже стоят на сайте, и каждая открывает профиль этой компании.' }
393
426
  : { task: 'Put the profiles the site already links to into the sameAs field of the organisation markup. Ask the owner for any other real profiles (maps, directories, social) and add only confirmed ones.', verify: 'The organisation markup carries sameAs with the profiles already linked on the site, each opening this company\'s profile.' }) : null),
394
427
  sources: ({ ru }) => (ru
395
428
  ? { task: 'Подпиши источник к каждой цифре статистики на сайте (доли, количества людей, сравнения «в N раз»): ссылкой на первоисточник или словами. Цены, адреса, телефоны и сроки услуг источника не требуют. Если источника нет ни у тебя, ни у владельца, цифру убери. Не ставь ссылку наугад.', verify: 'Рядом с каждой цифрой статистики есть ссылка на первоисточник или названный словами источник, либо цифра убрана.' }
396
429
  : { task: 'Add a source to every statistic on the site (shares, counts of people, "N times" comparisons): a link to the original or a named source. Prices, addresses, phones and service timings need none. If neither you nor the owner has a source, remove the figure. Never guess a link.', verify: 'Every statistic has a link or a named source beside it, or has been removed.' }),
397
- 'answer-first': ({ ru, v }) => (ru
398
- ? { task: `Перепиши начало главных страниц так, чтобы первый абзац сразу отвечал на вопрос ${v.peopleGen}: что это, кому и на каких условиях. От двадцати до девяноста слов, с конкретной цифрой из фактов владельца; источник цифры назови рядом. Если цифры нет, не придумывай: спроси владельца.`, verify: 'Первый абзац после заголовка на этих страницах укладывается в 20-90 слов и содержит цифру с названным источником.' }
399
- : { 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. 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 with its source named.' }),
430
+ // Какие страницы переписывать, называем адресами: «главные страницы» исполнителю ничего не говорит (cimes.ru, 14.10.2026).
431
+ 'answer-first': ({ audit, ru, v }) => { const noAnswer = sampleOf(audit).filter((p) => !p.answerFirst); const miss = noAnswer.filter((p) => !isListPage(p)).slice(0, 12).map((p) => pathOf(p.url)); const lists = noAnswer.filter(isListPage).map((p) => pathOf(p.url)); const where = (miss.length ? (ru ? ` Страницы выборки без такого начала: ${miss.join(', ')}.` : ` Sampled pages without such an opening: ${miss.join(', ')}.`) : '') + (lists.length ? (ru ? ` Остальные (${lists.slice(0, 5).join(', ')}) это страницы-списки подразделов, их начало переписывать не нужно.` : ` The others (${lists.slice(0, 5).join(', ')}) are list pages leading to sub-pages: leave their opening as it is.`) : ''); const r = (ru
432
+ ? { task: `Перепиши начало главных страниц так, чтобы первый абзац сразу отвечал на вопрос ${v.peopleGen}: что это, кому и на каких условиях. От двадцати до девяноста слов, с конкретной цифрой из фактов владельца; источник цифры назови рядом (у собственных цен и сроков источник сам владелец, отдельно его называть не нужно). Если цифры нет, не придумывай: спроси владельца.`, verify: 'Первый абзац после заголовка на этих страницах укладывается в 20-90 слов и содержит цифру; если цифра не своя (не цена и не срок владельца), рядом назван источник.' }
433
+ : { task: `Rewrite the opening of the main pages so the first paragraph answers what ${v.people} ask: what it is, for whom, on what terms. Twenty to ninety words with a concrete figure from the owner's facts, its source named beside it (the owner's own prices and timings need no source). If there is no figure, do not invent one: ask the owner.`, verify: 'The first paragraph after the heading on those pages is 20 to 90 words and carries a figure; if the figure is not the owner\'s own price or timing, its source is named beside it.' }); return { ...r, task: `${r.task}${where}` }; },
434
+
400
435
  };
401
436
 
402
437
  const HEAD = {
@@ -513,6 +548,10 @@ export function agentPrompt(check, opts = {}) {
513
548
  }
514
549
 
515
550
  /** Все задания по открытым находкам аудита, в порядке важности. */
551
+ const datesGeneric = ({ ru }) => (ru
552
+ ? { task: 'Сверь даты публикации в разметке (datePublished) с настоящими датами материалов: если тема сортирует записи по этой дате, сначала переключи сортировку на настоящую дату, потом исправь даты в разметке. Затем выведи дату публикации видимой строкой на странице каждого материала.', verify: 'На странице каждого материала видна дата публикации, и она совпадает с datePublished в его разметке.' }
553
+ : { task: 'Check the published dates in the markup (datePublished) against the real dates of the posts: if the theme sorts posts by that date, switch the sorting first, then correct the markup dates. Then show the published date as a visible line on every post.', verify: 'Every post shows its published date on the page, and it matches datePublished in its markup.' });
554
+
516
555
  export function agentPrompts(audit, opts = {}) {
517
556
  const lang = opts.lang === 'ru' ? 'ru' : 'en';
518
557
  const limit = Number(opts.limit ?? 0);
@@ -524,7 +563,9 @@ export function agentPrompts(audit, opts = {}) {
524
563
  const nicheNow = vocabFor(audit, lang).niche;
525
564
  const AGENT_ONLY = new Set(['agent-card', 'api-catalog', 'agent-markdown']);
526
565
  const checks = source.filter((c) => (c.status === 'bad' || c.status === 'warn') && !(AGENT_ONLY.has(c.id) && nicheNow !== 'b2b'));
527
- const ordered = [...checks.filter((c) => c.status === 'bad'), ...checks.filter((c) => c.status === 'warn')];
566
+ // Сначала приоритеты отчёта в их порядке, потом остальное: план говорит «остальные задачи по порядку из файла задач» (cimes.ru, 14.10.2026).
567
+ const pri = (audit?.priorityIds || []).map((id) => checks.find((c) => c.id === id)).filter(Boolean);
568
+ const ordered = [...pri, ...checks.filter((c) => c.status === 'bad' && !pri.includes(c)), ...checks.filter((c) => c.status === 'warn' && !pri.includes(c))];
528
569
  const chosen = limit > 0 ? ordered.slice(0, limit) : ordered;
529
570
  const platform = opts.platform || detectPlatform(audit);
530
571
  return chosen.map((c) => ({ id: c.id, hasOwnText: Boolean(P[promptKey(c.id)] || PF[promptKey(c.id)]), text: agentPrompt(c, { lang, platform, audit }) }));