@operstack/audit 0.33.4 → 0.34.1

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/index.mjs CHANGED
@@ -9,3 +9,9 @@ export { draftNarrative, stillEmpty } from './narrative.mjs';
9
9
  export { agentPrompt, agentPrompts, renderAgentPrompts, firstFixParts, FIRST_FIX_IDS } from './prompts.mjs';
10
10
  export { buildDemandMap, expandQueries, coverage, seedsFromPages, volumesViaTopvisor, topvisorClient, renderDemandHtml, renderDemandMarkdown, demandSourceRow, demandCsv, demandTotals, REGIONS as DEMAND_REGIONS, DEMAND_DEFAULTS } from './demand.mjs';
11
11
  export { buildLlmsTxt, buildOrganisationSchema, fixTitlesAndDescriptions } from './handover.mjs';
12
+ export { secondLook, siteDigest, rescore, rebuildCritical, assertVerified, coldTtfb } from './verify.mjs';
13
+ export { reviewAndFix, reviewRound, confirmFlags, statementsOf, applyFix, toolFacts } from './review.mjs';
14
+ export { consistencyCheck } from './consistency.mjs';
15
+ export { detectNiche, vocabFor } from './niche.mjs';
16
+ export { renderTasksHtml, renderTasksPdf } from './tasks-pdf.mjs';
17
+ export { printFitted, pdfPageCount } from './render.mjs';
package/src/narrative.mjs CHANGED
@@ -20,7 +20,7 @@
20
20
  const T = {
21
21
  ru: {
22
22
  subjectFrom: (d) => `По описанию на главной: ${d}. Это то, что о вас прочитает и человек, и поисковик, и ИИ.`,
23
- subjectNone: 'На главной не сказано, чем занимается компания. Это и есть первая проблема: если это непонятно человеку, это непонятно и поисковику, и ИИ.',
23
+ subjectNone: 'У главной страницы нет описания для поиска: сниппет в выдаче и в ответах ИИ поисковик подбирает сам из текста страницы.',
24
24
  unknownLang: 'не указан в коде страниц',
25
25
  /*
26
26
  * Обращение к аналитику из клиентского текста убрано 17.09.2026. «Подтвердите» писалось для
@@ -29,13 +29,13 @@ const T = {
29
29
  * само по себе находка: если из описания главной не понять, чем занимается компания, то это
30
30
  * не понять ни человеку, ни поисковику, ни ИИ.
31
31
  */
32
- sells: (d) => d ? `${d} (прочитано из описания главной страницы)` : 'на главной не сказано, чем занимается компания: ни человеку, ни поисковику, ни ИИ это отсюда не понять',
32
+ sells: (d, p) => d ? `${d} (прочитано из описания главной страницы)` : p ? `по тексту главной: «${p}»; описания для поиска у главной нет` : 'описания для поиска у главной нет, а из текста главной это не понять',
33
33
  leadWhat: (h, n) => `Мы прочитали ${n} ${plural(n, 'страницу', 'страницы', 'страниц')} сайта ${h} так же, как их открывают поисковик и ИИ.`,
34
34
  leadGood: (areas) => areas.length ? `Хорошо сделано вот что: ${listRu(areas)}.` : 'Ни одна из шести областей пока не выглядит сильной стороной.',
35
- leadBad: (area, n) => n ? `Держит сайт назад другое: ${n} ${plural(n, 'проверка провалена', 'проверки провалены', 'проверок провалено')}, тяжелее всего дела в области «${area}».` : `Проваленных проверок нет, но до потолка не хватает мелочей, тяжелее всего в области «${area}».`,
36
- verdict: (openN, mech) => openN
37
- ? `Главное: ${openN} ${plural(openN, 'вещь мешает', 'вещи мешают', 'вещей мешают')} сайту показываться и попадать в ответы ИИ. Из них ${mech} ${plural(mech, 'правится', 'правятся', 'правятся')} технически, без переписывания текстов, то есть быстро, а ${openN - mech} ${plural(openN - mech, 'потребует', 'потребуют', 'потребуют')} ваших фактов: цифр, источников, ответов на вопросы покупателей.`
38
- : 'Главное: критичных поломок нет. Дальше сайт растёт не починкой, а текстами: ответами на вопросы покупателей, цифрами и источниками, по которым вас можно процитировать.',
35
+ leadBad: (area, n) => n ? `Тянет сайт вниз другое: ${n} ${plural(n, 'проверка провалена', 'проверки провалены', 'проверок провалено')}, тяжелее всего дела в области «${area}».` : `Проваленных проверок нет, но до потолка не хватает мелочей, тяжелее всего в области «${area}».`,
36
+ verdict: (openN, mech, people = 'покупателей') => openN
37
+ ? `${openN} ${plural(openN, 'проверка пройдена', 'проверки пройдены', 'проверок пройдены')} не полностью. Из них ${mech} ${plural(mech, 'правится', 'правятся', 'правятся')} технически, без переписывания текстов, то есть быстро, а ${openN - mech} ${plural(openN - mech, 'потребует', 'потребуют', 'потребуют')} ваших фактов: цифр, источников, ответов на вопросы ${people}.`
38
+ : `Главное: критичных поломок нет. Дальше сайт растёт не починкой, а текстами: ответами на вопросы ${people}, цифрами и источниками, по которым вас можно процитировать.`,
39
39
  noteOffer: (cta, contact) => `Что видит человек, попавший на сайт: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
40
40
  ctaYes: 'на первом экране есть понятный призыв к действию',
41
41
  ctaNo: 'на первом экране нечего нажать, и это значит, что пришедший уходит',
@@ -71,15 +71,15 @@ const T = {
71
71
  },
72
72
  en: {
73
73
  subjectFrom: (d) => `From the homepage description: ${d}. This is what a person, a search engine and an AI all read about you.`,
74
- subjectNone: 'The homepage does not say what the business does. That is the first problem: if a person cannot tell, neither can a search engine or an AI.',
74
+ subjectNone: 'The home page has no search description: search results and AI answers pick a snippet from the page text on their own.',
75
75
  unknownLang: 'not declared in the page code',
76
- sells: (d) => d ? `${d} (read from the homepage description)` : 'the homepage does not say what the company does: neither a person, nor a search engine, nor an AI can tell from it',
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
78
  leadGood: (areas) => areas.length ? `What is done well: ${listEn(areas)}.` : 'None of the six areas reads as a strength yet.',
79
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}".`,
80
- verdict: (openN, mech) => openN
81
- ? `The point: ${openN} thing${openN === 1 ? '' : 's'} stop this site from being shown and from being quoted by AI. ${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 buyers ask.`
82
- : 'The point: nothing is broken. From here the site grows through text, not repair: answers to what buyers ask, figures, and the sources behind them.',
80
+ verdict: (openN, mech, people = 'buyers') => openN
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
+ : `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.`,
83
83
  noteOffer: (cta, contact) => `What a visitor meets: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
84
84
  ctaYes: 'the first screen has a clear thing to do',
85
85
  ctaNo: 'the first screen has nothing to click, which means arrivals leave',
@@ -118,12 +118,15 @@ function plural(n, one, few, many) {
118
118
  return d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 10 || dd >= 20) ? few : many;
119
119
  }
120
120
  const sep = (a) => (a.some((x) => String(x).includes(',')) ? '; ' : ', ');
121
- const listRu = (a) => (a.length < 2 ? a[0] || '' : sep(a) === '; ' ? a.join('; ') : `${a.slice(0, -1).join(', ')} и ${a[a.length - 1]}`);
121
+ // Внутри фразы названия областей со строчной: «...; конверсия и удобство», но SEO, AEO и GEO как есть.
122
+ const lowerMid = (x) => (/^[А-ЯЁ][а-яё]/.test(x) ? x[0].toLowerCase() + x.slice(1) : x);
123
+ const listRu = (a0) => { const a = a0.map(lowerMid); return a.length < 2 ? a[0] || '' : sep(a) === '; ' ? a.join('; ') : `${a.slice(0, -1).join(', ')} и ${a[a.length - 1]}`; };
122
124
  const listEn = (a) => (a.length < 2 ? a[0] || '' : sep(a) === '; ' ? a.join('; ') : `${a.slice(0, -1).join(', ')} and ${a[a.length - 1]}`);
123
125
 
124
126
  /** Проверки, которые Fix закрывает технически: ими меряется «сколько чинится быстро». */
125
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']);
126
128
 
129
+ import { vocabFor } from './niche.mjs';
127
130
  import { localiseChecks, AREAS_RU } from './i18n.mjs';
128
131
  import { FIX_ACTIONS } from './fix.mjs';
129
132
 
@@ -138,6 +141,7 @@ const open = (c) => c && (c.status === 'warn' || c.status === 'bad');
138
141
  /** Проверки, которые закрывает только текст клиента: у них есть своя формулировка работы. */
139
142
  const TEXT_WORK_IDS = new Set(['thin', 'answer-first', 'sources', 'sections', 'tables']);
140
143
 
144
+ export { MECHANICAL as MECHANICAL_IDS };
141
145
  export function draftNarrative(audit, opts = {}) {
142
146
  const lang = opts.lang || audit.meta?.lang || 'en';
143
147
  const t = T[lang === 'ru' ? 'ru' : 'en'];
@@ -177,7 +181,7 @@ export function draftNarrative(audit, opts = {}) {
177
181
  // Число это выборка, а не размер сайта: «сайт из 19 страниц» про магазин на сотни товаров неправда (12.10.2026).
178
182
  a.summary.lead = [t.leadWhat(host, sample.length), t.leadGood(strong), t.leadBad(weakest, bad.length)].join(' ');
179
183
  }
180
- if (String(a.summary.verdict).includes('{{')) a.summary.verdict = t.verdict(bad.length + warn.length, mechanical);
184
+ if (String(a.summary.verdict).includes('{{')) a.summary.verdict = t.verdict(bad.length + warn.length, mechanical, vocabFor(a, lang === 'ru' ? 'ru' : 'en').peopleGen);
181
185
  // Приоритет это не название проверки, а действие: владелец должен прочитать и понять, что делать.
182
186
  const actionText = (c) => {
183
187
  const rule = FIX_ACTIONS[c.id];
@@ -193,7 +197,16 @@ export function draftNarrative(audit, opts = {}) {
193
197
  if (rule && rule.why) return L === 'ru' ? `Это работа с текстами: ${rule.why.ru}` : `This is text work: ${rule.why.en}`;
194
198
  return c.label;
195
199
  };
196
- const action = (c) => `${actionText(c)}. ${lang === 'ru' ? 'Сейчас' : 'Now'}: ${c.value}`;
200
+ // Действие по замеру, а не по шаблону: «переписать описание, убрать служебный код» при пустом описании
201
+ // и «оставить ровно один H1» при нуле H1 вводили в заблуждение (14.10.2026).
202
+ const measuredAction = (c) => {
203
+ const ru = lang === 'ru'; const v = String(c.value || '');
204
+ 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';
205
+ if (c.id === 'h1' && /нет|no H1/i.test(v)) return ru ? 'Добавить на главную один заголовок H1 о том, чем занимается компания' : 'Add one H1 to the home page saying what the business does';
206
+ 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'; }
207
+ return null;
208
+ };
209
+ const action = (c) => `${measuredAction(c) || actionText(c)}. ${lang === 'ru' ? 'Сейчас' : 'Now'}: ${c.value}`;
197
210
  // В приоритеты идут только те находки, для которых есть действие. У части проверок в FIX_ACTIONS
198
211
  // вместо действия стоит довод против («это имеет смысл только тем, у кого правда есть API»), и
199
212
  // такая строка первым пунктом плана читается как отговорка, а не как задача.
@@ -212,7 +225,7 @@ export function draftNarrative(audit, opts = {}) {
212
225
  a.overview.rows = (a.overview.rows || []).map(([k, v]) => {
213
226
  if (!String(v).includes('{{')) return [k, v];
214
227
  if (/language|язык/i.test(k)) return [k, a.meta?.language || t.unknownLang];
215
- if (/sold|продаётся|продается/i.test(k)) return [k, t.sells(desc)];
228
+ if (/sold|продаётся|продается|чем занимается/i.test(k)) return [k, t.sells(desc, String(home.offerSentence || home.firstPara || '').trim().slice(0, 220).replace(/[\s.,;:]+$/, ''))];
216
229
  return [k, t.notMeasured];
217
230
  });
218
231
  if (String(a.overview.note).includes('{{')) {
@@ -230,7 +243,17 @@ export function draftNarrative(audit, opts = {}) {
230
243
  });
231
244
  a.content.strengths = fillList(a.content.strengths, group('content').filter((c) => c.status === 'ok'), t.strengthNone);
232
245
  a.content.weaknesses = fillList(a.content.weaknesses, group('content').filter(open), t.weakNone);
233
- a.content.gaps = fillList(a.content.gaps, group('content').filter((c) => c.status === 'bad'), t.gapsNone);
246
+ // «Структурные дыры» это страницы, которых нет, а не проваленные проверки текста (14.10.2026: туда попадали источники цифр).
247
+ {
248
+ const ru = lang === 'ru'; const miss = [];
249
+ const about = byId(checks, 'trust-about'); const pol = byId(checks, 'trust-policies');
250
+ if (about && about.status !== 'ok') miss.push(ru ? 'Нет страницы «О компании».' : 'There is no About page.');
251
+ if (pol && /политики конфиденциальности нет|neither a privacy policy|no privacy policy/i.test(String(pol.value))) miss.push(ru ? 'Нет страницы политики конфиденциальности.' : 'There is no privacy policy page.');
252
+ if (pol && /только в тексте страницы|only inside the text of/i.test(String(pol.value))) miss.push(ru ? 'Правила или условия услуг есть только внутри другой страницы, своей страницы у них нет.' : 'The terms sit inside another page and have no page of their own.');
253
+ const extraWeak = group('content').filter((c) => c.status === 'bad' && !group('content').filter(open).includes(c));
254
+ void extraWeak;
255
+ a.content.gaps = (a.content.gaps || []).some((x) => String(x).includes('{{')) ? (miss.length ? miss : [t.gapsNone]) : a.content.gaps;
256
+ }
234
257
  a.aeo.works = fillList(a.aeo.works, group('aeo').filter((c) => c.status === 'ok'), t.aeoWorksNone);
235
258
  a.aeo.blocks = fillList(a.aeo.blocks, group('aeo').filter(open), t.aeoBlocksNone);
236
259
  if (String(a.aeo.recommendation).includes('{{')) {
@@ -253,7 +276,9 @@ export function draftNarrative(audit, opts = {}) {
253
276
 
254
277
  const profilesCheck = byId(checks, 'trust-profiles');
255
278
  const profiles = profilesCheck && profilesCheck.status === 'ok' ? String(profilesCheck.value).split(/[,:]/).slice(1).map((s) => s.trim()).filter(Boolean) : [];
256
- a.offpage.listed = (a.offpage.listed || []).map((x) => String(x).includes('{{') ? t.offpageListed(profiles) : x);
279
+ // Ссылки на профили на самих страницах (второй взгляд их видит) тоже профиль, просто не в разметке (14.10.2026).
280
+ const linkedOnly = profilesCheck && /ссылк\S* на сайте есть|ссылка на канал|pages link to/i.test(String(profilesCheck.value));
281
+ 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);
257
282
  if (String(a.offpage.note).includes('{{')) a.offpage.note = t.offpageNote;
258
283
 
259
284
  a.conversion.rows = (a.conversion.rows || []).map(([label, status]) => {
package/src/niche.mjs ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Ниша сайта до советов (14.10.2026).
3
+ *
4
+ * Простым языком. Отчёт клинике советовал «кнопку Получить расчёт», разметку RealEstateAgent и
5
+ * «ответы на вопросы покупателей», а цены предлагал ставить на гемодиализ по ОМС. Советы были
6
+ * написаны под магазин и агентство недвижимости и шли всем. Здесь ниша определяется по тому, что
7
+ * сайт сам о себе говорит (разметка, заголовки, текст выборки), и от неё зависят слова, примеры
8
+ * кнопок и тип разметки. Не определилась: нейтральные слова и никаких нишевых примеров.
9
+ */
10
+
11
+ const SIGNALS = {
12
+ medical: {
13
+ schema: /MedicalOrganization|MedicalClinic|Hospital|Physician|Dentist|Pharmacy|MedicalBusiness|DiagnosticLab/i,
14
+ text: /(?<![\p{L}\p{N}])(?:пациент\p{L}*|врач\p{L}*|клиник\p{L}*|медицинск\p{L}*|лечени\p{L}*|диагностик\p{L}*|ОМС|ДМС|полис\p{L}*|анализ\p{L}*|процедур\p{L}*|гемодиализ\p{L}*|стоматолог\p{L}*|patient\p{L}*|clinic\p{L}*|doctor\p{L}*|treatment\p{L}*|medical|dental)(?![\p{L}\p{N}])/giu,
15
+ },
16
+ shop: {
17
+ schema: /\b(?:Product|OnlineStore|Store)\b/,
18
+ text: /(?<![\p{L}\p{N}])(?:корзин\p{L}*|в корзину|купить|доставк\p{L}*|артикул|add to cart|checkout|shipping|in stock|buy now)(?![\p{L}\p{N}])/giu,
19
+ },
20
+ realestate: {
21
+ schema: /RealEstateAgent|RealEstateListing|Residence|Apartment|House\b/,
22
+ text: /(?<![\p{L}\p{N}])(?:недвижимост\p{L}*|квартир\p{L}*|застройщик\p{L}*|вилл\p{L}*|кондо|ипотек\p{L}*|real estate|apartment\p{L}*|condo\p{L}*|villa\p{L}*|property|properties|developer)(?![\p{L}\p{N}])/giu,
23
+ },
24
+ b2b: {
25
+ schema: /SoftwareApplication|WebApplication/,
26
+ text: /(?<![\p{L}\p{N}])(?:для бизнеса|B2B|интеграци\p{L}*|API|SaaS|внедрени\p{L}*|enterprise|integration\p{L}*|platform|pricing plans?)(?![\p{L}\p{N}])/giu,
27
+ },
28
+ services: {
29
+ schema: /LocalBusiness|ProfessionalService|HomeAndConstructionBusiness|LegalService|AccountingService|BeautySalon|AutoRepair|ChildCare|\bService\b/,
30
+ text: /(?<![\p{L}\p{N}])(?:услуг\p{L}*|заявк\p{L}*|записаться|такси|поездк\p{L}*|водител\p{L}*|пассажир\p{L}*|мастер\p{L}*|ремонт\p{L}*|консультаци\p{L}*|service\p{L}*|book an? appointment|get a quote|consultation)(?![\p{L}\p{N}])/giu,
31
+ },
32
+ };
33
+
34
+ const ORDER = ['medical', 'shop', 'realestate', 'b2b', 'services'];
35
+
36
+ /** Слова и примеры по нише. other это нейтральный набор без нишевых примеров. */
37
+ export const VOCAB = {
38
+ medical: {
39
+ ru: { people: 'пациенты', peopleGen: 'пациентов', peopleDat: 'пациентам', cta: 'кнопка заявки, звонка или записи', schemaType: 'MedicalOrganization', price: 'Цены указывай только у платных услуг; у услуг по ОМС напиши, что они бесплатны по полису, и не придумывай цену.' },
40
+ en: { people: 'patients', peopleGen: 'patients', cta: '"Book an appointment" or "Call us" (only an action the site already offers)', schemaType: 'MedicalOrganization', price: 'Show prices only for paid services; do not invent prices for covered care.' },
41
+ },
42
+ shop: {
43
+ ru: { people: 'покупатели', peopleGen: 'покупателей', peopleDat: 'покупателям', cta: '«Купить», «В корзину» или «Посмотреть цены»', schemaType: 'Store или OnlineStore', price: 'Цена должна быть видна на странице товара и совпадать с разметкой Offer.' },
44
+ en: { people: 'buyers', peopleGen: 'buyers', cta: '"Buy", "Add to cart" or "See prices"', schemaType: 'Store or OnlineStore', price: 'The price must be visible on the product page and match the Offer markup.' },
45
+ },
46
+ realestate: {
47
+ ru: { people: 'покупатели', peopleGen: 'покупателей', peopleDat: 'покупателям', cta: '«Подобрать объект» или «Получить подборку»', schemaType: 'RealEstateAgent', price: 'Цены и условия оплаты бери только у владельца или застройщика.' },
48
+ en: { people: 'buyers', peopleGen: 'buyers', cta: '"Find a property" or "Get a shortlist"', schemaType: 'RealEstateAgent', price: 'Take prices and payment terms only from the owner or the developer.' },
49
+ },
50
+ b2b: {
51
+ ru: { people: 'клиенты', peopleGen: 'клиентов', peopleDat: 'клиентам', cta: '«Запросить демо» или «Узнать цену»', schemaType: 'Organization (и SoftwareApplication для продукта)', price: 'Цены пиши только те, что владелец готов публиковать.' },
52
+ en: { people: 'customers', peopleGen: 'customers', cta: '"Request a demo" or "See pricing"', schemaType: 'Organization (and SoftwareApplication for the product)', price: 'Publish only the prices the owner is ready to show.' },
53
+ },
54
+ services: {
55
+ ru: { people: 'клиенты', peopleGen: 'клиентов', peopleDat: 'клиентам', cta: '«Оставить заявку» или «Позвонить»', schemaType: 'LocalBusiness или ProfessionalService', price: 'Цены пиши только те, что владелец готов публиковать.' },
56
+ en: { people: 'customers', peopleGen: 'customers', cta: '"Request a call" or "Get a quote"', schemaType: 'LocalBusiness or ProfessionalService', price: 'Publish only the prices the owner is ready to show.' },
57
+ },
58
+ other: {
59
+ ru: { people: 'посетители', peopleGen: 'посетителей', peopleDat: 'посетителям', cta: 'кнопка или ссылка с понятной подписью', schemaType: 'Organization или более точный тип по роду деятельности', price: 'Цены пиши только те, что владелец готов публиковать.' },
60
+ en: { people: 'visitors', peopleGen: 'visitors', cta: 'the action the site already offers (a button or link with a clear label)', schemaType: 'Organization or a more precise type that fits the business', price: 'Publish only the prices the owner is ready to show.' },
61
+ },
62
+ };
63
+
64
+ /**
65
+ * @returns {{ niche: string, confidence: number, scores: Record<string, number> }}
66
+ * Ниша засчитывается, только если её сигнал заметно сильнее остальных: иначе other.
67
+ */
68
+ export function detectNiche(audit) {
69
+ const pages = (audit?.sample || []).filter((p) => p && p.title !== undefined);
70
+ const text = [audit?.meta?.description, audit?.client?.subject, ...pages.flatMap((p) => [p.title, p.h1, p.description, p.firstPara])].filter(Boolean).join(' \n ');
71
+ const schema = pages.flatMap((p) => p.schemaTypes || []).join(' ');
72
+ const scores = {};
73
+ for (const k of ORDER) {
74
+ const hits = (text.match(SIGNALS[k].text) || []).length;
75
+ const sch = SIGNALS[k].schema.test(schema) ? 6 : 0;
76
+ scores[k] = hits + sch;
77
+ }
78
+ // Движок магазина это сильный сигнал: Shopify и WooCommerce почти всегда продают товары.
79
+ const cms = String((audit?.checks || []).find((c) => c.id === 'cms')?.value || '');
80
+ if (/shopify|woocommerce/i.test(cms)) scores.shop += 6;
81
+ const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);
82
+ const [top, second] = ranked;
83
+ const confident = top[1] >= 4 && top[1] >= (second ? second[1] : 0) * 1.5;
84
+ return { niche: confident ? top[0] : 'other', confidence: top[1], scores };
85
+ }
86
+
87
+ export function vocabFor(audit, lang = audit?.meta?.lang || 'en') {
88
+ const n = audit?.niche?.niche || detectNiche(audit).niche;
89
+ const v = VOCAB[n] || VOCAB.other;
90
+ return { niche: n, ...(v[lang === 'ru' ? 'ru' : 'en']) };
91
+ }
package/src/prompts.mjs CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { FIX_ACTIONS } from './fix.mjs';
18
18
  import { localiseChecks } from './i18n.mjs';
19
+ import { vocabFor } from './niche.mjs';
19
20
 
20
21
  const P = {
21
22
  alt: {
@@ -189,8 +190,8 @@ const P = {
189
190
  en: { task: 'Add BreadcrumbList JSON-LD to the inner pages: the path from the home page to the current one, each step with a name and a full URL. Build the path from the site\'s real section structure; if the page already shows visible breadcrumbs, the markup must match them word for word.', verify: 'Inner sampled pages carry JSON-LD of type BreadcrumbList, every URL in it opens, and the validator shows no errors.' },
190
191
  },
191
192
  'org-schema': {
192
- ru: { task: 'Добавь в общий шаблон (или хотя бы на главную) разметку организации в JSON-LD: Organization или более точный тип (LocalBusiness, RealEstateAgent и подобные), с названием, адресом сайта, логотипом, контактами и ссылками на профили в sameAs. Заполняй только тем, что есть на сайте или что подтвердил владелец, остальные поля оставь пустыми.', verify: 'На главной есть JSON-LD с типом Organization или его подтипом, в нём name и url, и валидатор не показывает ошибок.' },
193
- en: { task: 'Add organisation JSON-LD to the shared template (or at least the home page): Organization or a more precise type (LocalBusiness, RealEstateAgent and the like), with the name, site address, logo, contacts and profile links in sameAs. Fill in only what is on the site or confirmed by the owner; leave the other fields out.', verify: 'The home page has JSON-LD of type Organization or a subtype, with name and url, and the validator shows no errors.' },
193
+ ru: { task: 'Добавь в общий шаблон (или хотя бы на главную) разметку организации в JSON-LD: Organization или более точный тип по роду бизнеса (например, MedicalOrganization для клиники, LocalBusiness для местной услуги, Store для магазина), с названием, адресом сайта, логотипом, контактами и ссылками на профили в sameAs. Заполняй только тем, что есть на сайте или что подтвердил владелец, остальные поля оставь пустыми.', verify: 'На главной есть JSON-LD с типом Organization или его подтипом, в нём name и url, и валидатор не показывает ошибок.' },
194
+ en: { task: 'Add organisation JSON-LD to the shared template (or at least the home page): Organization or a more precise type that fits the business (for example MedicalOrganization for a clinic, LocalBusiness for a local service, Store for a shop), with the name, site address, logo, contacts and profile links in sameAs. Fill in only what is on the site or confirmed by the owner; leave the other fields out.', verify: 'The home page has JSON-LD of type Organization or a subtype, with name and url, and the validator shows no errors.' },
194
195
  },
195
196
  favicon: {
196
197
  ru: { task: 'Добавь значок сайта: файл favicon (лучше SVG и запасной PNG 32 на 32 или ICO) из логотипа компании, и ссылку <link rel="icon"> на него в общем шаблоне. Логотип бери тот, что уже есть на сайте, новый не рисуй.', verify: 'В HTML главной есть <link rel="icon">, и файл по ссылке открывается с кодом 200.' },
@@ -258,6 +259,145 @@ const P = {
258
259
 
259
260
  const withLang = (v, lang) => v[lang === 'ru' ? 'ru' : 'en'];
260
261
 
262
+ /**
263
+ * Задания, текст которых зависит от того, что на сайте есть на самом деле (14.10.2026).
264
+ *
265
+ * Простым языком. Шаблонное задание врёт, когда сайт не такой, как в шаблоне: «убери лишний H1»
266
+ * там, где H1 нет вовсе; «проставь alt», когда alt уже стоит у всех, но пустой; «перепиши
267
+ * robots.txt», когда в нём единственное правильное правило. А «Как проверить» тогда проходит до
268
+ * всякой работы. Здесь задание собирается из замера: что именно сейчас, что сделать и как
269
+ * убедиться, что сделано, причём проверка не может пройти до начала работы.
270
+ * ctx: { check, audit, lang, ru, v (слова ниши), platform }
271
+ */
272
+ const sampleOf = (audit) => (audit?.sample || []).filter((p) => p && p.title !== undefined);
273
+ const pathOf = (u) => { try { return new URL(u).pathname; } catch { return String(u || ''); } };
274
+ const PF = {
275
+ title: ({ ru, v }) => (ru
276
+ ? { task: 'Перепиши заголовок главной (тег title): до 60 знаков, словами, которыми ищут ' + v.people + ', что это за компания и чем она занимается, название компании в конце. Не повторяй слова и не пиши список ключевых слов через запятую.', verify: 'Заголовок главной от 30 до 60 знаков, в нём названо, чем занимается компания, и он отличается от заголовков других страниц.' }
277
+ : { 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.' }),
281
+ 'trust-entity': ({ ru }) => (ru
282
+ ? { task: 'После того как появится разметка организации (задача про неё выше), допиши в неё адрес: город и страну (PostalAddress с addressLocality и addressCountry). Адрес головного офиса спроси у владельца, если на сайте его нет; адреса филиалов бери со страниц сайта.', verify: 'В разметке организации есть город и страна, и они совпадают с адресом, который подтвердил владелец.' }
283
+ : { 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.' }),
284
+ 'train-bots': ({ ru }) => (ru
285
+ ? { task: 'Спроси владельца, ставил ли он запрет для этих роботов. Если нет, выясни у хостинга, какое правило сервера или защиты обрывает их соединение, и сними его. Если владелец хочет закрыть обучающих роботов, оставь запрет, но пропиши его явно в robots.txt, чтобы было видно, что это решение.', verify: 'Запрос robots.txt с user-agent каждого названного робота возвращает ответ 200, либо запрет явно записан в robots.txt по решению владельца.' }
286
+ : { 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.' }),
287
+ alt: ({ audit, ru }) => {
288
+ const s = sampleOf(audit);
289
+ const missing = s.reduce((a, p) => a + (Number(p.imagesAltMissing) || 0), 0);
290
+ const empty = s.reduce((a, p) => a + (Number(p.imagesNoAlt) || 0), 0) - missing;
291
+ if (missing === 0 && empty > 0) return ru
292
+ ? { task: 'Атрибут alt стоит у всех картинок, но почти везде пустой. Впиши непустой alt каждой содержательной картинке (фото на страницах, в новостях, галереях и карточках): своими словами, что изображено, от пяти до пятнадцати слов. Значкам оформления темы (иконки, стрелки, фоны) оставь пустой alt="", это правильно. Если непонятно, что на фото, не выдумывай: собери такие файлы в список для владельца.', verify: 'У каждой фотографии на проверенных страницах alt не пустой и описывает изображение; пустой alt="" остался только у значков оформления.' }
293
+ : { 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="".' };
294
+ return null;
295
+ },
296
+ h1: ({ audit, ru }) => {
297
+ const home = sampleOf(audit)[0];
298
+ if (home && !home.h1Count) return ru
299
+ ? { task: 'Добавь на главную один заголовок первого уровня (H1) о том, чем занимается компания. Существующие заголовки H2 не трогай. Не делай H1 из логотипа и из названия сайта в шапке.', verify: 'На главной ровно один H1, и он говорит, чем занимается компания.' }
300
+ : { 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.' };
301
+ return null;
302
+ },
303
+ 'h1-sample': ({ audit, ru }) => {
304
+ const s = sampleOf(audit);
305
+ const none = s.filter((p) => !p.h1Count).length; const many = s.filter((p) => p.h1Count > 1).length;
306
+ if (none && !many) return ru
307
+ ? { task: 'На страницах без H1 добавь один заголовок первого уровня с названием страницы (на главной: чем занимается компания). Если H1 не выводит шаблон темы, правь шаблон, а не каждую страницу.', verify: 'На каждой проверенной странице ровно один H1. На внутренних страницах он совпадает с названием страницы, на главной говорит, чем занимается компания.' }
308
+ : { 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.' };
309
+ return null;
310
+ },
311
+ description: ({ check, ru }) => (/нет|missing|none|no description/i.test(String(check.value)) ? (ru
312
+ ? { task: 'Напиши описание главной (meta description) в 120-160 знаков: чем занимается компания и что человек найдёт на сайте. Одно предложение, без рекламных восклицаний. Не обещай того, чего на сайте нет.', verify: 'У главной есть описание длиной от 120 до 160 знаков, и оно говорит, чем занимается компания.' }
313
+ : { 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),
314
+ 'description-all': ({ ru }) => (ru
315
+ ? { task: 'Добавь meta description на страницы, где его нет: 120-160 знаков своими словами о том, что человек найдёт именно на этой странице. Бери смысл из текста страницы. Если описание пустое из-за шаблона или плагина, почини шаблон, чтобы он брал поле описания страницы.', verify: 'На каждой проверенной странице есть meta description длиной от 120 до 160 знаков, и описания не повторяются.' }
316
+ : { 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.' }),
317
+ robots: ({ check, ru, platform }) => {
318
+ const rules = Number((String(check.value).match(/(\d+)/) || [])[1]);
319
+ const onlySitemap = /строки с картой сайта нет|no sitemap line/i.test(String(check.value)) && rules <= 2;
320
+ if (!onlySitemap) return null;
321
+ const wp = platform === 'wordpress';
322
+ return ru
323
+ ? { task: `${wp ? 'robots.txt на WordPress часто собирается на лету, тогда файла на диске нет: создай его в плагине SEO (в Yoast: SEO, Инструменты, Редактор файлов) и сохрани нынешние строки. ' : ''}Добавь в конец robots.txt строку Sitemap с полным адресом карты сайта. Нынешние правила не трогай: закрытых обычных страниц в них нет.`, verify: 'В robots.txt есть строка Sitemap, и адрес в ней открывает карту сайта.' }
324
+ : { 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.' };
325
+ },
326
+ 'faq-schema': ({ ru, v }) => (ru
327
+ ? { task: `Собери с владельцем 5-7 частых вопросов ${v.peopleGen} с ответами, опубликуй их видимым блоком на страницах главных услуг и только потом разметь схемой FAQPage. Текст в разметке должен слово в слово совпадать с видимым. Вопросов, которых нет на странице, в разметку не добавляй.`, verify: 'На выбранных страницах есть разметка FAQPage минимум с пятью вопросами, и каждый вопрос и ответ из неё дословно виден на странице.' }
328
+ : { 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.' }),
329
+ sections: ({ audit, ru }) => {
330
+ const long = sampleOf(audit).filter((p) => (p.words || 0) >= 600 && (p.h2Count || 0) < 3);
331
+ if (!long.length) return null;
332
+ const list = long.slice(0, 5).map((p) => pathOf(p.url)).join(', ');
333
+ // Подзаголовки уже есть, но размечены H3: задача не «придумать H2», а поднять существующие (14.10.2026).
334
+ if (long.every((p) => (p.h3Count || 0) >= 2)) return ru
335
+ ? { task: `На ${long.length === 1 ? 'странице' : 'страницах'} ${list} подзаголовки уже есть, но размечены как H3. Сделай существующие смысловые подзаголовки H2; новые добавляй, только если без них раздел не читается. Текст не дописывай.`, verify: `${long.length === 1 ? 'На странице' : 'На страницах'} ${list} не меньше трёх заголовков H2 по смыслу текста.` }
336
+ : { task: `On ${list} the subheadings already exist but are marked up as H3. Make the existing meaningful subheadings H2; add new ones only where a section cannot be read without them. Do not add text.`, verify: `${list} carry at least three H2s that follow the text.` };
337
+ return ru
338
+ ? { task: `Разбей на разделы ${long.length === 1 ? 'длинную страницу, где' : 'длинные страницы, где'} сейчас меньше трёх заголовков H2: ${list}. Заголовки H2 по смыслу текста. Ничего не дописывай, только структурируй.`, verify: `${long.length === 1 ? 'На странице' : 'На страницах'} ${list} не меньше трёх заголовков H2 по смыслу текста.` }
339
+ : { task: `Split into sections the long pages that now have fewer than three H2s: ${list}. H2s that follow the text. Add nothing, only structure.`, verify: `The pages ${list} carry at least three H2s that follow the text.` };
340
+ },
341
+ 'conv-cta': ({ ru, v }) => (ru
342
+ ? { task: `Поставь на первый экран каждой главной страницы одно понятное действие, которое на сайте уже есть: ${v.cta}. Одно действие на экран. Не выдумывай новых обещаний и сроков.`, verify: 'На каждой такой странице в верхних 15 процентах высоты, в основном блоке страницы, а не в шапке, есть кнопка или ссылка с этим действием.' }
343
+ : { 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 has that button or link within the top 15 percent of its height, in the main content block rather than the header.' }),
344
+ 'conv-messenger': ({ ru }) => (ru
345
+ ? { task: 'Спроси владельца, есть ли у компании мессенджер для связи (WhatsApp, Telegram, MAX). Если есть, поставь одну ссылку рядом с телефоном, чтобы она открывала диалог. Если нет, задачу закрой.', verify: 'Либо рядом с телефоном есть рабочая ссылка на мессенджер, либо владелец подтвердил, что его нет.' }
346
+ : { 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.' }),
347
+ 'org-schema': ({ audit, ru, v }) => {
348
+ const links = (audit?.checks || []).find((c) => c.id === 'trust-profiles');
349
+ const has = /ссылки на сайте есть|pages link to/i.test(String(links?.value || ''));
350
+ return ru
351
+ ? { task: `Добавь в общий шаблон разметку организации в JSON-LD с типом ${v.schemaType}: название, адрес сайта, логотип, телефон, почта${has ? ' и ссылки на профили, которые уже стоят на сайте,' : ''} в sameAs. Заполняй только тем, что есть на сайте или что подтвердил владелец.`, verify: 'На главной есть JSON-LD организации с name и url, и валидатор разметки не показывает ошибок.' }
352
+ : { task: `Add organisation JSON-LD of type ${v.schemaType} to the shared template: name, site address, logo, phone, email${has ? ', and the profile links already on the site' : ''} in sameAs. Fill in only what is on the site or confirmed by the owner.`, verify: 'The home page carries organisation JSON-LD with name and url, and the validator shows no errors.' };
353
+ },
354
+ 'agent-card': ({ ru }) => (ru
355
+ ? { task: 'Спроси владельца, есть ли у компании свой ИИ-агент или сервис, который могут вызывать другие агенты. Если нет, ничего не делай: карточка агента описывает программного агента, а не компанию, и делать её ради галочки нельзя.', verify: 'Либо владелец подтвердил, что агента нет, либо /.well-known/agent.json отвечает 200 с типом application/json.' }
356
+ : { task: 'Ask the owner whether the business runs its own AI agent or a service other agents can call. If not, do nothing: an agent card describes a software agent, not a company, and making one for a tick is wrong.', verify: 'Either the owner confirmed there is no agent, or /.well-known/agent.json answers 200 with application/json.' }),
357
+ utility: ({ check, ru, platform }) => (/author/i.test(String(check.value)) && platform === 'wordpress' ? (ru
358
+ ? { task: 'Выключи архивы авторов в плагине SEO (в Yoast: SEO, Отображение в поисковой выдаче, вкладка Архивы; в английском интерфейсе Search Appearance, Archives), тогда страница автора уйдёт из карты сайта. Если имя автора выглядит как логин (например, admin), смени отображаемое имя в профиле пользователя WordPress на название компании.', verify: 'В карте сайта нет страниц авторов, и в разметке страниц нет имени-логина.' }
359
+ : { task: 'Switch off author archives in the SEO plugin (Yoast: Search Appearance, Archives), so the author page leaves the sitemap. If the author name looks like a login (admin, say), change the display name in the WordPress user profile to the company name.', verify: 'The sitemap has no author pages, and the page markup carries no login-like name.' }) : null),
360
+ ttfb: ({ ru }) => (ru
361
+ ? { task: 'Сначала сделай три замера из страны, где живут твои посетители. Если ответ больше 0,8 секунды (порог, который Google считает хорошим), включи кеширование готовых страниц: плагин кеша, кеш хостинга или CDN. Менять хостинг только из-за нашего замера не нужно: мы меряем не из твоей страны.', verify: 'Три замера подряд из страны посетителей (curl -o /dev/null -w "%{time_starttransfer}" адрес главной) показывают не больше 0,8 секунды.' }
362
+ : { task: 'First take three measurements from the country your visitors live in. If the answer takes over 0.8 seconds (the level Google calls good), turn on page caching: a cache plugin, host cache or a CDN. Do not move host on our measurement alone: we measure from elsewhere.', verify: 'Three runs in a row from your visitors\' country (curl -o /dev/null -w "%{time_starttransfer}" home URL) show 0.8 seconds or less.' }),
363
+ thin: ({ audit, ru, v }) => {
364
+ const sm = sampleOf(audit); const home = sm[0];
365
+ const homeH2 = (home?.h2List || []).join('|');
366
+ // Страницы, где все H2 те же, что на главной: это шаблон, обрамляющий почти пустую запись (14.10.2026, карточки врачей medeq-mc.ru).
367
+ const templated = homeH2 && (home.h2List || []).length >= 3 ? sm.slice(1).filter((p) => (p.h2List || []).join('|') === homeH2) : [];
368
+ // Список статей и страница отзывов с виджетом короткие по устройству, дописывать их не нужно (14.10.2026).
369
+ const LISTY = /\/(articles|blog|news|novosti|stati|otzyvy|reviews|testimonials|category|tag|page\/\d+)\/?$/i;
370
+ const shortReal = sm.filter((p) => (p.words || 0) < 300 && !LISTY.test(pathOf(p.url)));
371
+ if (!templated.length && !shortReal.length && sm.some((p) => (p.words || 0) < 300)) return ru
372
+ ? { task: `Короткие страницы выборки это списки и отзывы (${sm.filter((p) => (p.words || 0) < 300).map((p) => pathOf(p.url)).join(', ')}): дописывать их не нужно. Если отзывы подгружаются виджетом, проверь, что хотя бы несколько отзывов видны в самом коде страницы, иначе поиск и ИИ их не читают.`, verify: 'На странице отзывов несколько отзывов видны в исходном коде страницы, а не только в виджете.' }
373
+ : { 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.' };
374
+ if (templated.length >= 2) {
375
+ const list = templated.slice(0, 4).map((p) => pathOf(p.url)).join(', ');
376
+ return ru
377
+ ? { task: `На ${templated.length} страницах выборки (например, ${list}) своего текста почти нет: шаблон показывает блоки главной вместо содержания записи. Сделай для таких записей свой шаблон: заголовок H1 с названием (для сотрудника ФИО), а дальше данные записи (должность, филиал, опыт). Если записи не нужны в поиске, закрой их noindex. Факты бери только у владельца.`, verify: 'На этих страницах есть свой H1 и свои данные записи, а блоки главной на них больше не повторяются (или страницы закрыты noindex по решению владельца).' }
378
+ : { 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).' };
379
+ }
380
+ return ru
381
+ ? { task: `Допиши короткие страницы фактами, которые знает владелец и которые нужны ${v.peopleDat || v.people}: что это, кому, как, где и когда. ${v.price} Не разбавляй текст водой ради объёма. Если фактов нет, выпиши владельцу список вопросов.`, verify: 'Каждая дописанная страница стала длиннее 300 слов за счёт фактов (названия, условия, адреса, сроки), а не общих слов.' }
382
+ : { 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.' };
383
+ },
384
+ 'trust-policies': ({ audit, ru }) => (audit?.verification?.consentBox ? (ru
385
+ ? { task: 'Галочка согласия на обработку данных у формы уже есть, не хватает самой политики конфиденциальности. Её текст готовит владелец или юрист, не сочиняй. Когда текст будет, опубликуй его отдельной страницей, поставь ссылку на неё в подвал и рядом с галочкой согласия у формы.', verify: 'Страница политики конфиденциальности открывается с кодом 200, ссылка на неё есть в подвале каждой страницы и рядом с галочкой согласия у формы.' }
386
+ : { task: 'The form already has a consent checkbox; what is missing is the privacy policy itself. The owner or a lawyer writes it. Once it exists, publish it as a page and link it from the footer and next to the consent checkbox.', verify: 'The privacy policy page answers 200 and is linked from every footer and next to the consent checkbox.' })
387
+ : null) || (ru
388
+ ? { task: 'Политику конфиденциальности и согласие на обработку персональных данных готовит владелец или юрист: текст не сочиняй. Когда текст будет, опубликуй его отдельной страницей, поставь ссылку в подвал и добавь к каждой форме, которая собирает имя, телефон или почту, галочку согласия со ссылкой на политику. Если правила или условия услуг лежат внутри другой страницы, вынеси их на отдельную и тоже поставь ссылку в подвал.', verify: 'В подвале каждой страницы есть ссылка на политику, она открывается с кодом 200, и у каждой формы есть галочка согласия.' }
389
+ : { task: 'The owner or a lawyer writes the privacy policy and terms: do not draft legal text. Once it exists, publish it as its own page, link it from the footer, and add a consent checkbox linking to it on every form that collects a name, phone or email.', verify: 'Every page footer links the policy, it answers 200, and every form has a consent checkbox.' }),
390
+ 'trust-profiles': ({ check, ru }) => (/ссылки на сайте есть|pages link to/i.test(String(check.value)) ? (ru
391
+ ? { task: 'Профили, на которые сайт уже ссылается, впиши в поле sameAs разметки организации. Спроси владельца, есть ли другие настоящие профили (карты, отраслевые каталоги, соцсети), и добавь только подтверждённые.', verify: 'В разметке организации есть sameAs со ссылками на профили, которые уже стоят на сайте, и каждая открывает профиль этой компании.' }
392
+ : { task: 'Put the profiles the site already links to into the sameAs field of the organisation markup. Ask the owner for any other real profiles (maps, directories, social) and add only confirmed ones.', verify: 'The organisation markup carries sameAs with the profiles already linked on the site, each opening this company\'s profile.' }) : null),
393
+ sources: ({ ru }) => (ru
394
+ ? { task: 'Подпиши источник к каждой цифре статистики на сайте (доли, количества людей, сравнения «в N раз»): ссылкой на первоисточник или словами. Цены, адреса, телефоны и сроки услуг источника не требуют. Если источника нет ни у тебя, ни у владельца, цифру убери. Не ставь ссылку наугад.', verify: 'Рядом с каждой цифрой статистики есть ссылка на первоисточник или названный словами источник, либо цифра убрана.' }
395
+ : { task: 'Add a source to every statistic on the site (shares, counts of people, "N times" comparisons): a link to the original or a named source. Prices, addresses, phones and service timings need none. If neither you nor the owner has a source, remove the figure. Never guess a link.', verify: 'Every statistic has a link or a named source beside it, or has been removed.' }),
396
+ 'answer-first': ({ ru, v }) => (ru
397
+ ? { task: `Перепиши начало главных страниц так, чтобы первый абзац сразу отвечал на вопрос ${v.peopleGen}: что это, кому и на каких условиях. От двадцати до девяноста слов, с конкретной цифрой из фактов владельца; источник цифры назови рядом. Если цифры нет, не придумывай: спроси владельца.`, verify: 'Первый абзац после заголовка на этих страницах укладывается в 20-90 слов и содержит цифру с названным источником.' }
398
+ : { 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.' }),
399
+ };
400
+
261
401
  const HEAD = {
262
402
  ru: { now: 'Сейчас', task: 'Задача', verify: 'Как проверить', pages: 'Страницы, где это видно', genericVerify: 'Прогони проверку сайта заново: эта строка должна перестать быть проблемой, а остальные не должны испортиться.', rule: 'Правило: не выдумывай факты о бизнесе. Всё, чего нет на сайте и чего тебе не сказали, спрашивай у владельца и оставляй пометку.' },
263
403
  en: { now: 'Now', task: 'Task', verify: 'How to check', pages: 'Pages where this shows', genericVerify: 'Run the site check again: this line must stop being a finding, and nothing else may get worse.', rule: 'Rule: invent no facts about the business. Anything not on the site and not told to you, ask the owner and leave a marker.' },
@@ -350,7 +490,13 @@ export function agentPrompt(check, opts = {}) {
350
490
  const h = HEAD[lang];
351
491
  const noForm = check.id === 'conv-form-depth' && /no form|формы нет/i.test(String(check.value || ''));
352
492
  const key = noForm ? 'conv-form-missing' : promptKey(check.id);
353
- const own = P[key] ? withLang(P[key], lang) : null;
493
+ // Задание по замеру (PF) важнее шаблонного (P): шаблон не знает, что на этом сайте на самом деле.
494
+ const ctx = { check, audit: opts.audit || null, lang, ru: lang === 'ru', v: vocabFor(opts.audit || {}, lang), platform: opts.platform || null };
495
+ const measured = PF[key] ? PF[key](ctx) : null;
496
+ const base = measured || (P[key] ? withLang(P[key], lang) : null);
497
+ // Правка проверяющего ИИ (review.mjs) важнее любого шаблона: она сверена с сайтом.
498
+ const over = opts.audit?.taskOverrides?.[check.id];
499
+ const own = over ? { task: over.task || base?.task || check.label, verify: over.verify || base?.verify || HEAD[lang].genericVerify } : base;
354
500
  const rule = FIX_ACTIONS[check.id];
355
501
  const fallback = rule && rule.action ? withLang(rule.action, lang) : (rule && rule.why ? withLang(rule.why, lang) : check.label);
356
502
  const title = noForm ? (lang === 'ru' ? 'Форма заявки' : 'Enquiry form') : check.label;
@@ -372,11 +518,15 @@ export function agentPrompts(audit, opts = {}) {
372
518
  // Русский файл по английскому аудиту: названия и значения проверок переводим, иначе владелец
373
519
  // читает русское задание с английскими вставками и не понимает, про что оно.
374
520
  const source = lang === 'ru' && audit.meta?.lang !== 'ru' ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
375
- const checks = source.filter((c) => c.status === 'bad' || c.status === 'warn');
521
+ // Карточка агента, каталог API и markdown нужны сайтам, где есть свой ИИ-сервис или API: клинике, магазину и
522
+ // агентству это задачи ради галочки (14.10.2026). Остальным нишам эти строки в задачи не идут.
523
+ const nicheNow = vocabFor(audit, lang).niche;
524
+ const AGENT_ONLY = new Set(['agent-card', 'api-catalog', 'agent-markdown']);
525
+ const checks = source.filter((c) => (c.status === 'bad' || c.status === 'warn') && !(AGENT_ONLY.has(c.id) && nicheNow !== 'b2b'));
376
526
  const ordered = [...checks.filter((c) => c.status === 'bad'), ...checks.filter((c) => c.status === 'warn')];
377
527
  const chosen = limit > 0 ? ordered.slice(0, limit) : ordered;
378
528
  const platform = opts.platform || detectPlatform(audit);
379
- return chosen.map((c) => ({ id: c.id, hasOwnText: Boolean(P[promptKey(c.id)]), text: agentPrompt(c, { lang, platform }) }));
529
+ return chosen.map((c) => ({ id: c.id, hasOwnText: Boolean(P[promptKey(c.id)] || PF[promptKey(c.id)]), text: agentPrompt(c, { lang, platform, audit }) }));
380
530
  }
381
531
 
382
532
  /** Файл, который отдаётся клиенту вместе с отчётом. */