@operstack/audit 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/narrative.mjs CHANGED
@@ -30,24 +30,25 @@ const T = {
30
30
  * не понять ни человеку, ни поисковику, ни ИИ.
31
31
  */
32
32
  sells: (d, p) => d ? `${d} (прочитано из описания главной страницы)` : p ? `по тексту главной: «${p}»; описания для поиска у главной нет` : 'описания для поиска у главной нет, а из текста главной это не понять',
33
- leadWhat: (h, n) => `Мы прочитали ${n} ${plural(n, 'страницу', 'страницы', 'страниц')} сайта ${h} так же, как их открывают поисковик и ИИ.`,
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, 'проверка', 'проверки', 'проверок')}, они ниже` : ''}.`,
33
+ leadWhat: (h, n) => `Мы прочитали ${n} ${plural(n, 'страницу', 'страницы', 'страниц')} сайта ${h} так же, как ${n % 10 === 1 && n % 100 !== 11 ? 'её' : 'их'} открывают поисковик и ИИ.`,
34
+ leadGood: (areas) => areas.length === 6 ? 'Все шесть областей проверки в хорошем состоянии.' : areas.length ? `Хорошо сделаны области ${listRu(areas.map((x) => `«${x}»`))}.` : 'Ни одна из шести областей пока не прошла все свои проверки без замечаний.',
35
+ leadBad: (area, n, partial = 0) => n ? `Балл ${n % 10 === 1 && n % 100 !== 11 ? 'снижает' : 'снижают'} ${n} ${plural(n, 'проваленная проверка', 'проваленные проверки', 'проваленных проверок')}${area ? (n === 1 ? `, она в области «${area}»` : `, больше всего их в области «${area}»`) : ''}.` : area ? `Ни одна проверка не провалена, но часть засчитана наполовину, больше всего в области «${area}».` : `Ни одна проверка не провалена${partial ? `, ${partial} ${plural(partial, 'проверка засчитана', 'проверки засчитаны', 'проверок засчитаны')} наполовину, они ниже` : ''}.`,
36
36
  verdict: (openN, mech, people = 'покупателей') => openN
37
37
  ? `${openN} ${plural(openN, 'проверка пройдена', 'проверки пройдены', 'проверок пройдены')} не полностью. ${mech === 0 ? `Все они требуют ваших фактов: цифр, источников, ответов на вопросы ${people}.` : mech === openN ? 'Все они правятся технически, без переписывания текстов, то есть быстро.' : `Из них ${mech} ${plural(mech, 'правится', 'правятся', 'правятся')} технически, без переписывания текстов, то есть быстро, а ${openN - mech} ${plural(openN - mech, 'потребует', 'потребуют', 'потребуют')} ваших фактов: цифр, источников, ответов на вопросы ${people}.`}`
38
38
  : `Главное: критичных поломок нет. Дальше сайт растёт не починкой, а текстами: ответами на вопросы ${people}, цифрами и источниками, по которым вас можно процитировать.`,
39
- noteOffer: (cta, contact) => `Что видит человек, попавший на сайт: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
39
+ noteOffer: (cta, contact) => `Что видит человек, попавший на сайт: ${cta}. ${/^(?:www\.|https?:|\/)/.test(contact) ? contact : `${contact[0].toUpperCase()}${contact.slice(1)}`}.`,
40
40
  ctaYes: 'на первом экране есть понятный призыв к действию',
41
41
  ctaNo: 'на первом экране нечего нажать, и это значит, что пришедший уходит',
42
42
  ctaSome: 'призыв к действию на первом экране есть не на всех страницах',
43
43
  contactYes: 'связаться можно с любой проверенной страницы',
44
44
  contactNo: 'связаться с большинства страниц нельзя, человеку приходится искать',
45
- strengthNone: 'Сильных сторон в текстах проверки не нашли.',
45
+ contactSome: (n, m) => `связаться можно с ${n} из ${m} проверенных страниц, на остальных нет ни формы, ни контактов`,
46
+ strengthNone: 'Среди проверок содержания страниц пока ни одна не пройдена полностью; сильные стороны других областей видны в их таблицах.',
46
47
  weakNone: 'В текстах существующих страниц слабых мест не нашли.',
47
48
  gapsNone: 'Пробелов по страницам из выборки не видно.',
48
49
  aeoWorksNone: 'Пока ничего из того, что помогает попасть в ответы, на сайте не работает.',
49
50
  aeoBlocksNone: 'Прямых помех для попадания в ответы не нашли.',
50
- aeoRec: (p) => p ? `Начните с одной страницы: ${p}. Поставьте в её начало прямой ответ с цифрой; если цифра не ваша цена или срок, назовите рядом её источник. Так страницу можно процитировать.` : 'Поставьте в начало каждой важной страницы прямой ответ с цифрой и назовите рядом источник. Цитируют то, что можно вырезать одним абзацем.',
51
+ aeoRec: (p, h1Fig, near) => p ? (near ? `Начните с одной страницы: ${p}. Под заголовком там уже стоит фраза с цифрой: «${near}». Разверните её в абзац на 20-90 слов: что это, кому и на каких условиях, с этой цифрой. Так страницу можно процитировать.` : h1Fig ? `Начните с одной страницы: ${p}. Цифра там уже стоит в заголовке, но под ним нет абзаца, где ответ и цифра сказаны вместе: поставьте сразу под заголовком абзац на 20-90 слов, который отвечает на вопрос покупателя и повторяет эту цифру с условиями. Так страницу можно процитировать.` : `Начните с одной страницы: ${p}. Поставьте в её начало прямой ответ с цифрой; если цифра не ваша цена или срок, назовите рядом её источник. Так страницу можно процитировать.`) : 'Поставьте в начало каждой важной страницы прямой ответ с цифрой и назовите рядом источник. Цитируют то, что можно вырезать одним абзацем.',
51
52
  geoEntity: ['Кто вы, понятно машине', (ok) => ok ? 'да' : 'нет'],
52
53
  notMeasured: 'не измеряли',
53
54
  geoMentions: 'Упоминания на чужих сайтах',
@@ -61,7 +62,8 @@ const T = {
61
62
  convForm: (v) => v,
62
63
  priceRow: 'Цена видна',
63
64
  priceNo: 'на проверенных страницах цены нет',
64
- priceYes: (n, total) => `да: цена есть на ${n} из ${total} проверенных страниц`,
65
+ // «да» при цене на 1 из 19 спорило с цифрой (perina.by, 15.10.2026): только числа.
66
+ priceYes: (n, total) => (n === total ? `цена есть на всех ${total} проверенных ${total % 10 === 1 && total % 100 !== 11 ? 'странице' : 'страницах'}` : `цена есть на ${n} из ${total} проверенных ${total % 10 === 1 && total % 100 !== 11 ? 'страницы' : 'страниц'}`),
65
67
  closing: (host, bad) => bad
66
68
  ? `Мы посмотрели ${host} глазами машины и написали, что увидели, без общих слов. Сначала делается первая неделя плана: три главных приоритета этого отчёта, потом остальные задачи по порядку. Для части задач нужны ваши факты: цифры, сроки, сведения о компании. Их знаете только вы, и исполнитель спросит их у вас.`
67
69
  : `Мы посмотрели ${host} глазами машины. Технически сайт в порядке, чинить нечего, и это честный вывод, а не комплимент. Рост дальше только через тексты: ответы на вопросы покупателей с цифрами и источниками.`,
@@ -75,23 +77,24 @@ const T = {
75
77
  unknownLang: 'not declared in the page code',
76
78
  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
79
  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 === 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.',
80
+ leadGood: (areas) => areas.length === 6 ? 'All six areas of the check are in good shape.' : areas.length ? `Done well: the ${listEn(areas.map((x) => `"${x}"`))} ${areas.length === 1 ? 'area' : 'areas'}.` : 'None of the six areas has passed all its checks without a remark yet.',
79
81
  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
82
  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.`
83
+ ? `${openN} check${openN === 1 ? ' is' : 's are'} not fully passed. ${mech} of them are mechanical, quick to fix because no text has to be rewritten; the other ${openN - mech} need your facts: figures, sources, answers to what ${people} ask.`
82
84
  : `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
- noteOffer: (cta, contact) => `What a visitor meets: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
85
+ noteOffer: (cta, contact) => `What a visitor meets: ${cta}. ${/^(?:www\.|https?:|\/)/.test(contact) ? contact : `${contact[0].toUpperCase()}${contact.slice(1)}`}.`,
84
86
  ctaYes: 'the first screen has a clear thing to do',
85
87
  ctaNo: 'the first screen has nothing to click, which means arrivals leave',
86
88
  ctaSome: 'only some pages give the first screen a clear thing to do',
87
89
  contactYes: 'getting in touch is possible from almost any page',
88
90
  contactNo: 'most pages offer no way to get in touch, so a visitor has to go looking',
89
- strengthNone: 'The checks found no strength in the text.',
91
+ contactSome: (n, m) => `getting in touch is possible from ${n} of ${m} pages we read; the rest offer neither a form nor contacts`,
92
+ strengthNone: 'No content check passes in full yet; strengths in other areas show in their own tables.',
90
93
  weakNone: 'No weakness found in the text of the existing pages.',
91
94
  gapsNone: 'No gaps are visible across the sampled pages.',
92
95
  aeoWorksNone: 'Nothing that helps a page get quoted is working yet.',
93
96
  aeoBlocksNone: 'Nothing directly blocks a page from being quoted.',
94
- aeoRec: (p) => p ? `Start with one page: ${p}. Put a direct answer carrying a figure at the top and name where that figure comes from, right beside it. That is what makes a page quotable.` : 'Put a direct answer carrying a figure at the top of every page that matters, and name the source beside it. What gets quoted is what can be lifted in one paragraph.',
97
+ aeoRec: (p, h1Fig, near) => p ? (near ? `Start with one page: ${p}. A line with a figure already sits under its heading: "${near}". Expand it into a 20 to 90 word paragraph: what it is, for whom, on what terms, with that figure. That is what makes a page quotable.` : h1Fig ? `Start with one page: ${p}. The figure is already in its heading, but no paragraph under it gives the answer and the figure together: add a 20 to 90 word paragraph right below the heading that answers the buyer's question and repeats that figure with its terms. That is what makes a page quotable.` : `Start with one page: ${p}. Put a direct answer carrying a figure at the top and name where that figure comes from, right beside it. That is what makes a page quotable.`) : 'Put a direct answer carrying a figure at the top of every page that matters, and name the source beside it. What gets quoted is what can be lifted in one paragraph.',
95
98
  notMeasured: 'not measured',
96
99
  geoMentions: 'Mentions elsewhere',
97
100
  geoReviews: 'Reviews and press',
@@ -103,7 +106,7 @@ const T = {
103
106
  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
107
  priceRow: 'Price visible',
105
108
  priceNo: 'no price on the pages we read',
106
- priceYes: (n, total) => `yes: a price is shown on ${n} of ${total} pages we read`,
109
+ priceYes: (n, total) => (n === total ? `a price is shown on all ${total} pages we read` : `a price is shown on ${n} of ${total} pages we read`),
107
110
  closing: (host, bad) => bad
108
111
  ? `We looked at ${host} the way a machine looks at it and wrote down what we saw, without generalities. What gets fixed first is what is mechanical: it is quick and it shows in the numbers. Then comes the text, and there we need you, because only you know the figures and facts of your business.`
109
112
  : `We looked at ${host} the way a machine looks at it. Technically it is in order and there is nothing to repair, and that is a finding, not a compliment. Growth from here comes through text: answers to buyers' questions, with figures and sources.`,
@@ -130,7 +133,7 @@ import { vocabFor } from './niche.mjs';
130
133
  import { localiseChecks, AREAS_RU } from './i18n.mjs';
131
134
  import { FIX_ACTIONS } from './fix.mjs';
132
135
  import { snapshotFacts } from './facts.mjs';
133
- import { isListPage } from './pagekind.mjs';
136
+ import { isListPage, noAnswerNeeded, isLegalPage } from './pagekind.mjs';
134
137
 
135
138
  const byId = (checks, id) => (checks || []).find((c) => c.id === id);
136
139
  const open = (c) => c && (c.status === 'warn' || c.status === 'bad');
@@ -176,12 +179,16 @@ export function draftNarrative(audit, opts = {}) {
176
179
  // «Хорошо сделано» только для области без провалов и не больше чем с одной частичной проверкой: иначе её проблемы
177
180
  // стоят в приоритетах, и абзац называет одну область и сильной, и главной бедой (cimes.ru, 14.10.2026).
178
181
  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; };
182
+ // Строго: в области все проверки в норме. С одной частичной проверкой проверяющий видел противоречие: область
183
+ // «хорошо сделано», а соседняя с той же оценкой нет (прогон на 20 сайтах, 15.10.2026).
184
+ const clean = (k) => { const g = AREA_GROUPS[k] || []; const own = checks.filter((c) => g.includes(c.group)); return own.length > 0 && !own.some((c) => c.status === 'bad' || c.status === 'warn'); };
180
185
  const strong = scores.filter(([k, v]) => v >= 8 && clean(k)).map(([k]) => areaName(k));
181
186
  const weakest = areaName(scores.slice().sort((x, y) => x[1] - y[1])[0]?.[0] || '');
182
187
 
183
188
  // Кто клиент. Имя это адрес сайта, предмет прочитан с главной и помечен как прочитанный.
184
- const desc = String(home.description || '').trim().slice(0, 180).replace(/[\s.]+$/, '');
189
+ // Режем по концу предложения, а не на 180-м знаке: «проконсультируем по установк» читалось как сломанный текст (filterosmos.ru, 15.10.2026).
190
+ const clip = (x, n) => { const v = String(x || '').trim(); if (v.length <= n) return v; const cut = v.slice(0, n); const stop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? ')); return stop > n * 0.4 ? cut.slice(0, stop) : `${cut.replace(/[\s,;:]+\S*$/, '')}\u2026`; };
191
+ const desc = clip(home.description, 260).replace(/[\s.!]+$/, '').replace(/^www\./i, (m) => m.toLowerCase());
185
192
  a.client.name = a.client.name?.includes('{{') ? host : a.client.name;
186
193
  if (String(a.client.subject).includes('{{')) a.client.subject = desc ? t.subjectFrom(desc) : t.subjectNone;
187
194
 
@@ -193,7 +200,7 @@ export function draftNarrative(audit, opts = {}) {
193
200
  // «Прочитали 20» и «ни на одной из 150 прочитанных» рядом без объяснения путали владельца (cimes.ru, 14.10.2026).
194
201
  const readAll = Number(a.verification?.pagesRead) || 0;
195
202
  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".`) : '';
203
+ const more = readAll > sample.length ? (lang === 'ru' ? `Кроме того, чтобы проверить каждое «нет на сайте», мы открыли ${readAll}${smTotal > readAll ? ` из ${smTotal}` : ''} ${smTotal > readAll ? (smTotal % 10 === 1 && smTotal % 100 !== 11 ? 'адреса' : 'адресов') : plural(readAll, 'адрес', 'адреса', 'адресов').replace(/^\d+ /, '')} карты сайта (${sample.length} страниц выборки входят в это число).` : `We also read ${readAll} pages from the sitemap to check every "not on the site".`) : '';
197
204
  a.summary.lead = [t.leadWhat(host, sample.length), more, t.leadGood(strong), t.leadBad(weakestScore >= 8 ? '' : weakest, bad.length, warn.length)].join(' ');
198
205
  }
199
206
  if (String(a.summary.verdict).includes('{{')) a.summary.verdict = t.verdict(bad.length + warn.length, mechanical, vocabFor(a, lang === 'ru' ? 'ru' : 'en').peopleGen);
@@ -205,7 +212,7 @@ export function draftNarrative(audit, opts = {}) {
205
212
  // Проверки, которые закрываются только фактами клиента: называем работу, а не оправдание.
206
213
  const TEXT_WORK = {
207
214
  ru: { thin: 'Наполнить короткие страницы фактами: цифрами, условиями, сроками', 'answer-first': 'Поставить в начало страницы прямой ответ с цифрой', sources: 'Подписать к каждой цифре, откуда она взята', sections: 'Разбить страницу на разделы по вопросам читателя', tables: 'Собрать таблицу из ваших данных: таблицы цитируют охотнее абзацев' },
208
- en: { thin: 'Fill the short pages with facts: figures, terms, timings', 'answer-first': 'Put a direct answer carrying a figure at the top of the page', sources: 'Name where each figure comes from', sections: 'Break the page into sections that follow the reader\'s questions', tables: 'Build a table from your data: tables get quoted more readily than prose' },
215
+ en: { thin: 'Fill the short pages with facts: figures, terms, timings', 'answer-first': 'Put a direct answer carrying a figure at the top of the page', sources: 'Name the source of each figure', sections: 'Break the page into sections that follow the reader\'s questions', tables: 'Build a table from your data: tables get quoted more readily than prose' },
209
216
  };
210
217
  const own = TEXT_WORK[L][c.id];
211
218
  if (own) return own;
@@ -217,13 +224,31 @@ export function draftNarrative(audit, opts = {}) {
217
224
  const measuredAction = (c) => {
218
225
  const ru = lang === 'ru'; const v = String(c.value || '');
219
226
  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';
227
+ // Описание и заголовок по длине: «убрать служебный код» при чистом описании и «уложить в 60 знаков» заголовок из 14 знаков
228
+ // читались как ошибка (indatalabs.com, marilav.ru, filterosmos.ru, 16.10.2026).
229
+ { const len = Number((v.match(/(\d+)/) || [])[1] || 0);
230
+ if (c.id === 'description' && len && !/shortcode|garbage|служебный код/i.test(v)) { if (len > 160) return ru ? `Сократить описание главной до 120-160 знаков` : `Shorten the home page description to 120 to 160 characters`; if (len < 120) return ru ? `Дописать описание главной до 120-160 знаков` : `Extend the home page description to 120 to 160 characters`; }
231
+ if (c.id === 'title' && len) { if (len < 30) return ru ? `Дописать заголовок главной до 40-60 знаков: что это за компания и чем занимается` : `Extend the home page title to 40 to 60 characters: what the business is and does`; if (len > 60) return ru ? `Сократить заголовок главной до 60 знаков` : `Shorten the home page title to 60 characters`; } }
232
+ // llms.txt: чинить ровно то, что найдено, а не «убрать мёртвые и закрытые», когда найдена одна переадресация (indatalabs.com, 16.10.2026).
233
+ if (c.id === 'llms-health' && v) return ru ? 'Поправить в llms.txt те адреса, что названы в строке «Сейчас»: переадресацию заменить конечным адресом, неработающие и закрытые от индексации убрать' : 'Fix in llms.txt the addresses named under Now: replace a redirect with its final address, drop broken or noindexed ones';
220
234
  if (c.id === 'h1' && /нет|no H1/i.test(v)) return ru ? 'Добавить на главную один заголовок H1 о том, чем занимается компания' : 'Add one H1 to the home page saying what the business does';
235
+ // «Свести к одному H1», когда находка «H1 нет на 2 страницах», читалось как о лишних H1 (babynanny.ru, 15.10.2026).
236
+ if (c.id === 'h1-sample') {
237
+ const pg = (a.sample || []).filter((p) => p && p.title !== undefined);
238
+ const none = pg.filter((p) => !p.h1Count).map((p) => { try { return new URL(p.url).pathname; } catch { return p.url; } });
239
+ const many = pg.filter((p) => p.h1Count > 1).length;
240
+ if (none.length && !many) return ru ? `Добавить заголовок H1 на страницы без него (${none.join(', ')})` : `Add an H1 to the pages without one (${none.join(', ')})`;
241
+ if (none.length && many) return ru ? `Добавить H1 на страницы без него (${none.join(', ')}) и оставить по одному H1 там, где их несколько` : `Add an H1 where it is missing (${none.join(', ')}) and keep one H1 where there are several`;
242
+ }
221
243
  // Какие страницы разбивать, называем: «разбить страницу» без адреса владельцу не говорит, о какой речь (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)`; }
244
+ if (c.id === 'answer-first') { const missPages = (a.sample || []).filter((p) => p && p.title !== undefined && !p.answerFirst && !noAnswerNeeded(p)); const nearN = missPages.filter((p) => p.answerNear).length; const miss = missPages.map((p) => { try { return new URL(p.url).pathname; } catch { return p.url; } }); if (miss.length === 1) return ru ? `Поставить в начало ${miss[0] === '/' ? 'главной' : `страницы ${miss[0]}`} прямой ответ с цифрой${nearN ? ': короткая фраза с цифрой под заголовком уже есть, её достаточно развернуть' : ''}` : `Put a direct answer with a figure at the top of ${miss[0] === '/' ? 'the home page' : `the page ${miss[0]}`}${nearN ? ': a short line with a figure already sits under the heading and only needs expanding' : ''}`;
245
+ if (miss.length) return ru ? `Поставить в начало страниц прямой ответ с цифрой, начиная с ${miss.slice(0, 3).join(', ')} (всего таких страниц с текстом в выборке ${miss.length}, список в файле задач; страницы-списки переписывать не нужно${nearN ? `; на ${nearN} из них под заголовком уже есть короткая фраза с цифрой, её достаточно развернуть` : ''})` : `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${nearN ? `; ${nearN} of them already have a short line with a figure under the heading, which only needs expanding` : ''})`; }
223
246
  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
247
  if (c.id === 'trust-profiles' && c.messengersOnly) return ru ? 'Найти и указать в разметке настоящие профили компании на площадках' : 'Find and declare the company\'s real platform profiles in the markup';
248
+ // Формы нет: «поднять форму выше» поднимает то, чего нет (прогон на 20 сайтах, 15.10.2026).
249
+ if (c.id === 'conv-form-depth' && /no form|формы нет|формы заявки нет/i.test(v)) return ru ? 'Если заявки принимаются через сайт, поставить короткую форму на главную' : 'If you take enquiries through the site, add a short form to the home page';
225
250
  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}`; }
251
+ if (c.id === 'sections') { const long = (a.sample || []).filter((p) => p && p.title !== undefined && (p.words || 0) >= 600 && (p.h2Count || 0) < 3 && !isListPage(p)).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}`; }
227
252
  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'; }
228
253
  return null;
229
254
  };
@@ -261,13 +286,17 @@ export function draftNarrative(audit, opts = {}) {
261
286
  if (!String(v).includes('{{')) return [k, v];
262
287
  // «ru» владелец не читает: название языка словом (cimes.ru, 14.10.2026).
263
288
  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]; }
264
- if (/sold|продаётся|продается|чем занимается/i.test(k)) return [k, t.sells(desc, String(home.offerSentence || home.firstPara || '').trim().slice(0, 220).replace(/[\s.,;:]+$/, ''))];
289
+ if (/sold|продаётся|продается|чем занимается/i.test(k)) return [k, t.sells(desc, clip(home.offerSentence || home.firstPara, 260).replace(/[\s.,;:!]+$/, ''))];
265
290
  return [k, t.notMeasured];
266
291
  });
267
292
  if (String(a.overview.note).includes('{{')) {
268
293
  const cta = byId(checks, 'conv-cta');
269
294
  const path = byId(checks, 'conv-contact-path');
270
- a.overview.note = t.noteOffer(open(cta) ? (cta.status === 'bad' ? t.ctaNo : t.ctaSome) : t.ctaYes, open(path) ? t.contactNo : t.contactYes);
295
+ // «С большинства страниц нельзя» только когда так и есть: при 19 из 20 обзор писал это же (oper-stack.ru, 15.10.2026).
296
+ const pn = (String(path?.value || '').match(/(\d+)\s*(?:из|of)\s*(\d+)/) || []).slice(1).map(Number);
297
+ const contact = !open(path) ? t.contactYes : pn.length === 2 && pn[1] && pn[0] > 0 ? t.contactSome(pn[0], pn[1]) : t.contactNo;
298
+ // «Большинства нельзя» при контакте на 10 из 20 было противоречием с таблицей (etochay.ru, 15.10.2026): с числами пишем числа.
299
+ a.overview.note = t.noteOffer(open(cta) ? (cta.status === 'bad' ? t.ctaNo : t.ctaSome) : t.ctaYes, contact);
271
300
  }
272
301
 
273
302
  // Контент, AEO, GEO, ссылки, конверсия: строки собираются из своих же групп проверок.
@@ -286,7 +315,7 @@ export function draftNarrative(audit, opts = {}) {
286
315
  a.content.strengths = fillList(a.content.strengths, group('content').filter((c) => c.status === 'ok'), t.strengthNone);
287
316
  // Слабые места текстов могут быть в разделе ответов ИИ (первый абзац, разделы): тогда «не нашли» здесь противоречит ему (cimes.ru, 14.10.2026).
288
317
  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);
318
+ a.content.weaknesses = fillList(a.content.weaknesses, group('content').filter(open), aeoOpen.length ? (lang === 'ru' ? `Слабые места текстов относятся к ответам ИИ и описаны ниже, в разделе «Ответные системы: AEO»: ${aeoOpen.slice(0, 3).map((c) => (/^\p{Lu}\p{Ll}/u.test(c.label) ? c.label[0].toLowerCase() + c.label.slice(1) : c.label)).join(', ')}.` : `The text weaknesses concern AI answers and are covered in that section below: ${aeoOpen.slice(0, 3).map((c) => (/^\p{Lu}\p{Ll}/u.test(c.label) ? c.label[0].toLowerCase() + c.label.slice(1) : c.label)).join(', ')}.`) : t.weakNone);
290
319
  // «Структурные дыры» это страницы, которых нет, а не проваленные проверки текста (14.10.2026: туда попадали источники цифр).
291
320
  {
292
321
  const ru = lang === 'ru'; const miss = [];
@@ -302,8 +331,10 @@ export function draftNarrative(audit, opts = {}) {
302
331
  a.aeo.blocks = fillList(a.aeo.blocks, group('aeo').filter(open), t.aeoBlocksNone);
303
332
  if (String(a.aeo.recommendation).includes('{{')) {
304
333
  // Та же первая страница, что в приоритете и в задаче: три места советовали начать с разных (cimes.ru, 15.10.2026).
305
- const thinnest = sample.filter((p) => p.answerFirst === false && !isListPage(p))[0] || sample.filter((p) => p.answerFirst === false)[0];
306
- a.aeo.recommendation = t.aeoRec(thinnest ? thinnest.url : '');
334
+ const thinnest = sample.filter((p) => p.answerFirst === false && !noAnswerNeeded(p))[0] || sample.filter((p) => p.answerFirst === false && !isLegalPage(p))[0];
335
+ // Цифра уже в H1 (moregroupestate.ru: «Новые проекты на Пхукете от 4,6 млн ₽»): совет говорит, чего именно не хватает,
336
+ // иначе владелец читает «поставьте цифру» при цифре в заголовке (прогон на своих сайтах, 15.10.2026).
337
+ a.aeo.recommendation = t.aeoRec(thinnest ? thinnest.url : '', Boolean(thinnest && /(?<![\p{L}\d])\d+(?:[\s.,]\d+)*/u.test(String(thinnest.h1 || ''))), thinnest?.answerNear || '');
307
338
  }
308
339
 
309
340
  const entity = byId(checks, 'trust-entity') || byId(checks, 'org-schema');
package/src/niche.mjs CHANGED
@@ -34,30 +34,31 @@ const SIGNALS = {
34
34
  const ORDER = ['medical', 'shop', 'realestate', 'b2b', 'services'];
35
35
 
36
36
  /** Слова и примеры по нише. other это нейтральный набор без нишевых примеров. */
37
+ // Где на сайте главное содержание: «страницы услуг» у магазина детского белья читались как ошибка (прогон на 20 сайтах, 15.10.2026).
37
38
  export const VOCAB = {
38
39
  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.' },
40
+ ru: { mainGen: 'страниц услуг', mainLoc: 'страницах услуг', people: 'пациенты', peopleGen: 'пациентов', peopleDat: 'пациентам', cta: 'кнопка заявки, звонка или записи', schemaType: 'MedicalOrganization', price: 'Цены указывай только у платных услуг; у услуг по ОМС напиши, что они бесплатны по полису, и не придумывай цену.' },
41
+ en: { mainPages: 'service pages', 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
  },
42
43
  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.' },
44
+ ru: { mainGen: 'страниц каталога и товаров', mainLoc: 'страницах каталога и товаров', people: 'покупатели', peopleGen: 'покупателей', peopleDat: 'покупателям', cta: '«Купить», «В корзину» или «Посмотреть цены»', schemaType: 'Store или OnlineStore', price: 'Цена должна быть видна на странице товара и совпадать с разметкой Offer.' },
45
+ en: { mainPages: 'catalogue and product pages', 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
  },
46
47
  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.' },
48
+ ru: { mainGen: 'страниц объектов', mainLoc: 'страницах объектов', people: 'покупатели', peopleGen: 'покупателей', peopleDat: 'покупателям', cta: '«Подобрать объект» или «Получить подборку»', schemaType: 'RealEstateAgent', price: 'Цены и условия оплаты бери только у владельца или застройщика.' },
49
+ en: { mainPages: 'listing pages', 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
  },
50
51
  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.' },
52
+ ru: { mainGen: 'страниц продукта и услуг', mainLoc: 'страницах продукта и услуг', people: 'клиенты', peopleGen: 'клиентов', peopleDat: 'клиентам', cta: '«Запросить демо» или «Узнать цену»', schemaType: 'Organization (и SoftwareApplication для продукта)', price: 'Цены пиши только те, что владелец готов публиковать.' },
53
+ en: { mainPages: 'product and service pages', 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
  },
54
55
  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.' },
56
+ ru: { mainGen: 'страниц услуг', mainLoc: 'страницах услуг', people: 'клиенты', peopleGen: 'клиентов', peopleDat: 'клиентам', cta: '«Оставить заявку» или «Позвонить»', schemaType: 'LocalBusiness или ProfessionalService', price: 'Цены пиши только те, что владелец готов публиковать.' },
57
+ en: { mainPages: 'service pages', 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
  },
58
59
  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.' },
60
+ ru: { mainGen: 'основных страниц', mainLoc: 'основных страницах', people: 'посетители', peopleGen: 'посетителей', peopleDat: 'посетителям', cta: 'кнопка или ссылка с понятной подписью', schemaType: 'Organization или более точный тип по роду деятельности', price: 'Цены пиши только те, что владелец готов публиковать.' },
61
+ en: { mainPages: 'main pages', 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
  },
62
63
  };
63
64
 
package/src/pagekind.mjs CHANGED
@@ -3,4 +3,21 @@
3
3
  * разводит по подразделам: переписывать её начало и разбивать на разделы не нужно. Раньше задача про разделы
4
4
  * признавала /services/integration/ списком, а задача про первый абзац тут же требовала его переписать.
5
5
  */
6
- export const isListPage = (p) => (p?.schemaTypes || []).some((t) => /^(CollectionPage|ItemList)$/.test(t));
6
+ // Без разметки список узнаём по ссылкам вглубь раздела: 12 и больше (у /gajdy/ moregroupestate.ru их 135, 15.10.2026).
7
+ // Главная не список, даже со ссылками вглубь: на refreshdekor.ru отчёт советовал «начните с главной», а задача тут же
8
+ // называла главную страницей-списком, которую переписывать не нужно (проверяющий, 15.10.2026).
9
+ const isHome = (p) => { try { return new URL(p?.url).pathname.replace(/\/+$/, '') === ''; } catch { return false; } };
10
+ export const isListPage = (p) => !isHome(p) && ((p?.schemaTypes || []).some((t) => /^(CollectionPage|ItemList)$/.test(t)) || (p?.childLinks || 0) >= 12);
11
+ /**
12
+ * Юридическая страница: политика, условия, cookie, согласие, оферта. Ответ с цифрой ей не нужен, и совет «поставьте
13
+ * ответ с цифрой на /privacy/» владелец читает как невнимательность (прогон на своих сайтах, 15.10.2026).
14
+ */
15
+ const LEGAL_PATH = /\/(privacy|privacy-policy|terms|terms-of-service|terms-and-conditions|tos|legal|cookies?|cookie-policy|disclaimer|impressum|politika|politika-konfidencialnosti|policy|policies|soglasie|soglashenie|oferta|offer-agreement|personal-data|personalnye-dannye|konfidencialnost|agreement|dogovor-oferta)(\/|$|\.)/i;
16
+ const LEGAL_TITLE = /политика конфиденциальности|пользовательское соглашение|согласие на обработку|публичная оферта|политика обработки|privacy policy|terms of (service|use)|cookie policy|terms and conditions/i;
17
+ export const isLegalPage = (p) => { let path = ''; try { path = new URL(p?.url).pathname; } catch { path = ''; } return LEGAL_PATH.test(path) || LEGAL_TITLE.test(`${p?.title || ''} ${p?.h1 || ''}`); };
18
+ /** Странице не нужен ответ с цифрой в начале: список подразделов или юридический текст. */
19
+ export const noAnswerNeeded = (p) => isListPage(p) || isLegalPage(p);
20
+
21
+ /** Страница контактов: цифры на ней это адрес, телефон и часы, источник им не нужен (filterosmos.ru, indatalabs.com, 16.10.2026). */
22
+ const CONTACT_PATH = /\/(contacts?|contact-us|kontakty?|kontakti|svyaz|obratnaya-svyaz|контакты)\/?$/i;
23
+ export const isContactPage = (p) => { let path = ''; try { path = decodeURI(new URL(p?.url).pathname); } catch { path = ''; } return CONTACT_PATH.test(path); };
@@ -36,7 +36,8 @@ const QUOTED = /«[^»]*»|"[^"]*"|“[^”]*”/g;
36
36
  /** Поля, где стоит текст с сайта клиента: тире там его, не наше. */
37
37
  const SITE_TEXT = (id) => id === 'sub:subject' || /^ov:row:/.test(id) || id === 'chk:schema:value';
38
38
 
39
- const pagePaths = (t) => [...new Set((String(t).match(/(?<![\w.])\/[\w%.-]+(?:\/[\w%.-]+)*\/?/g) || []).filter((p) => p.length > 1))];
39
+ // Главная в списке пишется одним «/»: без неё «/, /about/, /contacts/, /shops/» считались за три страницы (filterosmos.ru, 15.10.2026).
40
+ export const pagePaths = (t) => { const s = String(t); const list = (s.match(/(?<![\w.])\/[\w%.-]+(?:\/[\w%.-]+)*\/?/g) || []).map((p) => p.replace(/\.+$/, "")).filter((p) => p.length > 1); if (/(?:^|[\s:(,])\/(?=[,;)\s.]|$)/.test(s)) list.unshift('/'); return [...new Set(list)]; };
40
41
 
41
42
  /**
42
43
  * @returns {{ ok: boolean, problems: { rule: number, id: string, why: string, text: string }[] }}
@@ -77,7 +78,7 @@ export function presentationCheck(audit, { lang = audit.meta?.lang || 'en' } = {
77
78
  if (t) {
78
79
  const listed = pagePaths(String(t.text).split(/\*\*(?:Как проверить|How to check):\*\*/)[0].replace(/\*\*(?:Сейчас|Now):\*\*[^\n]*/, ''));
79
80
  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
+ if (listed.length && listed.length < Math.min(need, 5) && !/вошли только|вошли страницы|only the long|остальные|the others|есть только на|is on only|юридическим страницам|legal pages/i.test(t.text)) add(7, `task:${c.id}`, `в отчёте ${need} страниц, в задаче названо ${listed.length}, правило отбора не объяснено`, t.text);
81
82
  }
82
83
  }
83
84