@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/collect.mjs CHANGED
@@ -3,10 +3,13 @@
3
3
  * robots.txt, sitemaps, llms.txt, the homepage and a sample of pages from the sitemap.
4
4
  * No Search Console, no analytics. The narrative fields are left for the analyst.
5
5
  */
6
+ import { isLegalPage, isContactPage, isListPage } from './pagekind.mjs';
6
7
  import { readFileSync } from 'node:fs';
7
8
  import { dirname, resolve } from 'node:path';
8
9
  import { fileURLToPath } from 'node:url';
9
10
  import { renderedDom, compareReadings, jsBlindnessCheck } from './rendered.mjs';
11
+ import { leadAnswer } from './visibility.mjs';
12
+ import { ctaScan, MESSENGER_HREF } from './client-eye.mjs';
10
13
  import { parse } from 'node-html-parser';
11
14
  import { profileLine } from './i18n.mjs';
12
15
  import { localiseChecks } from './i18n.mjs';
@@ -14,6 +17,32 @@ import { checkVisibility, VISIBILITY_DEFAULTS, internalLinks, robotsAllows, link
14
17
 
15
18
  /** Версия читается из своего package.json. Зашитая строка врала в каждом отчёте с первого релиза:
16
19
  * в подвале стояло 0.1.0, когда пакет был уже 0.12.0, и покупатель не мог понять, чем его мерили. */
20
+ /** Цена видна в тексте страницы: без выпадающих списков форм, «800 р.» тоже цена, нулевая сумма корзины нет. */
21
+ /** Телефон, почта и адрес с улицей в видимом тексте страницы (с шапкой и подвалом). Пустая строка, если нет. */
22
+ /**
23
+ * Видимый блок вопросов и ответов на странице (без разметки FAQPage тоже): три вопроса и больше в заголовках, summary,
24
+ * dt, strong или кнопках раскрывашек, или заголовок «Частые вопросы» и два вопроса. pilatesvw.ru: блок «Вопросы, которые
25
+ * нам часто задают» из 8 вопросов был, а задача просила собрать вопросы с нуля (15.10.2026).
26
+ */
27
+ export function faqBlockIn(html) {
28
+ const s = String(html || '').replace(/<(script|style|noscript|template|svg)\b[\s\S]*?<\/\1>/gi, ' ');
29
+ const plainT = (x) => x.replace(/<[^>]+>/g, ' ').replace(/&nbsp;|&#160;/g, ' ').replace(/\s+/g, ' ').trim();
30
+ const qs = [...s.matchAll(/<(h[2-6]|summary|dt|strong|b|button)\b[^>]*>([\s\S]{5,240}?)<\/\1>/gi)].map((m) => plainT(m[2])).filter((t) => /\?\s*$/.test(t)).length;
31
+ const head = /(частые вопросы|частых вопросов|вопросы и ответы|часто задают|ответы на вопросы|\bFAQ\b|frequently asked|questions and answers)/i.test(plainT(s));
32
+ return qs >= 3 || (head && qs >= 2);
33
+ }
34
+ // Год не номер дома: «2026 Botanica Grand Avenue» это название проекта после «© 2026» (moregroupestate.ru, 15.10.2026).
35
+ export function contactsInText(html) {
36
+ const text = String(html || '').replace(/<(script|style|noscript|template|svg)\b[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;|&#160;/g, ' ').replace(/&#43;|&plus;/g, '+').replace(/\s+/g, ' ');
37
+ const phone = (text.match(/(?:\+7|(?<![\d+])8)[\s(-]*\d{3}[\s)-]*\d{3}[\s-]*\d{2}[\s-]*\d{2}(?!\d)|\+\d{1,3}[\s.-]?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}(?!\d)/) || [''])[0];
38
+ const email = (text.match(/[\w.+-]+@[\w-]+(?:\.[\w-]+)*\.[a-zа-я]{2,}/i) || [''])[0];
39
+ const address = (text.match(/(?:\d{6},?\s*)?(?:г\.\s*[А-ЯЁ][а-яё-]+,?\s*)?(?:ул\.|улица|пр-т|проспект|пер\.|переулок|шоссе|наб\.|набережная|проезд|бульвар|б-р)\s*[А-ЯЁа-яё0-9 .-]{2,40}?,?\s*(?:д\.|дом)?\s*\d+[а-яА-Я]?/) || text.match(/(?<!©\s?)\b(?!(?:19|20)\d{2}\b)\d{1,5}\s+[A-Z][a-z]+(?:\s[A-Z][a-z]+)*\s(?:Street|St\.?|Road|Rd\.?|Avenue|Ave\.?|Boulevard|Blvd\.?|Lane|Ln\.?|Drive|Dr\.?|Way)\b/) || [''])[0];
40
+ return { phone: phone.trim(), email: email.trim(), address: address.trim() };
41
+ }
42
+ export function visiblePrice(html) {
43
+ const text = String(html || '').replace(/<(select|datalist|script|style|noscript|template)\b[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;|&#160;/g, ' ').replace(/\s+/g, ' ');
44
+ return /(?:[$€£₽]\s?(?:[1-9]|0[.,]\d*[1-9])|(?<![\d.,])[1-9][\d\s.,]*\s?(?:₽|руб(?:\.|лей|ля|ль)?|р\.(?=[\s,;)]|$)|USD|EUR|GBP)(?![\p{L}]))/u.test(text);
45
+ }
17
46
  const VERSION = (() => {
18
47
  try { return JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')).version; }
19
48
  catch { return '0'; }
@@ -132,13 +161,37 @@ async function getFollowed(url, opts = {}) {
132
161
  * Оферта проверяется целым словом: испанское /ofertas/ это акции, а не условия.
133
162
  */
134
163
  const POLICY_SLUGS = [
135
- ['privacy', /^(privacy|politika|[a-z-]*konfiden|политика|[а-я-]*конфиденциальн)/],
164
+ // «/policy» и «/police» тоже политика (svetak.ru, прогон на 20 сайтах 15.10.2026: отчёт писал «отдельной страницы нет» при живой /policy).
165
+ ['privacy', /^(privacy|politika|polic(?:y|ies|e)(?![a-z])|[a-z-]*konfiden|personal-data|personalnye-dannye|obrabotka-dannyh|политика|[а-я-]*конфиденциальн)/],
136
166
  ['terms', /^(terms|usloviya|oferta(?![a-z])|polzovatelskoe-soglashenie|условия|оферта|пользовательское-соглашение)/],
137
- ['consent', /^(soglasie|согласие)/],
167
+ ['consent', /^(soglasie|agree(?:ment)?(?![a-z])|согласие)/],
138
168
  ['legal', /^legal/],
139
169
  ['cookie', /^cookie/],
140
170
  ['disclaimer', /^disclaimer/],
141
171
  ];
172
+ /**
173
+ * Правовая страница по тексту ссылки: «Политика конфиденциальности» в подвале ведёт и на адрес без говорящего слова
174
+ * (marilav.ru, прогон на 20 сайтах 15.10.2026: отчёт писал «политики нет ни на одной из 36 страниц»).
175
+ */
176
+ const POLICY_TEXTS = [
177
+ ['privacy', /^(политика (?:конфиденциальности|обработки персональных данных|в отношении обработки)|конфиденциальность|персональные данные|privacy(?: policy| notice)?)/i],
178
+ ['terms', /^(пользовательское соглашение|публичная оферта|договор[ -]оферт|условия (?:использования|оказания услуг)|оферта|terms(?: of (?:use|service)| (?:&|and) conditions)?)/i],
179
+ ['consent', /^(согласие на обработку)/i],
180
+ ['cookie', /^(политика (?:cookie|куки)|cookie(?:s)? policy)/i],
181
+ ];
182
+ export function policyKindsByText(text) {
183
+ const t = String(text || '').replace(/\s+/g, ' ').trim();
184
+ return t.length > 80 ? [] : POLICY_TEXTS.filter(([, re]) => re.test(t)).map(([k]) => k);
185
+ }
186
+ /**
187
+ * Страница о компании по адресу и по тексту ссылки, одно правило для сборщика и для сверки (verify.mjs).
188
+ * «/obo-mne» и «Meet the CEO» тоже о компании (babynanny.ru, прогон на 20 сайтах 15.10.2026).
189
+ */
190
+ export const ABOUT_PATH = /\/(about|about-us|about-me|about-company|our-story|our-team|our-practice|team|company|meet-[a-z-]+|o-nas|o-kompanii|o-czentre|o-centre|o-klinike|o-vrache|kompaniya|obo-mne|o-sebe|ob-avtore|o-proekte|nasha-komanda|komanda|o-studii|о-нас|о-компании)\/?($|[?#])/i;
191
+ export const ABOUT_TEXT = /^(о нас|о компании|о клинике|о центре|о студии|обо мне|о себе|об авторе|о проекте|о враче|наша команда|команда|about|about us|about me|our story|our team|who we are|meet the [\p{L} ]{2,30})$/iu;
192
+ const AI_TRAINERS = /^(gptbot|claudebot|claude-web|anthropic-ai|ccbot|google-extended|applebot-extended|bytespider|amazonbot|meta-externalagent|meta-externalfetcher|facebookbot|cohere-ai|cohere-training-data-crawler|diffbot|omgili|omgilibot|youbot|imagesiftbot|timpibot|ai2bot|img2dataset|webzio-extended|friendlycrawler|iaskspider\/?\d*|panscient|duckassistbot-training|mistralai-user|novaact|deepseekbot|gemini-deep-research)$/;
193
+ /** Из списка роботов только обучающие роботы ИИ (SEO-сервисы и поисковики сюда не попадают). */
194
+ export const aiTrainers = (agents) => (agents || []).filter((a) => AI_TRAINERS.test(String(a).toLowerCase()));
142
195
  export function policyKinds(href) {
143
196
  const h = String(href || '').toLowerCase();
144
197
  if (/^(mailto|tel|javascript|data):/.test(h) || h.startsWith('#')) return [];
@@ -250,6 +303,8 @@ export function analysePage(url, html) {
250
303
  // Слова считаем по содержимому без меню и подвала: иначе страница из одного абзаца в большом
251
304
  // шаблоне выглядит на три тысячи слов и проверка на пустые страницы не значит ничего.
252
305
  const words = countWords(plain(bodyHtml)) || countWords(text);
306
+ // Язык по тексту страницы: у русского сайта на Tilda бывает lang="en", и отчёт писал «Язык: английский» (прогон на 20 сайтах, 15.10.2026).
307
+ const textLang = (() => { const cyr = (text.match(/[а-яё]/gi) || []).length; const lat = (text.match(/[a-z]/gi) || []).length; return cyr + lat < 200 ? '' : cyr > lat * 1.5 ? 'ru' : lat > cyr * 1.5 ? 'en' : ''; })();
253
308
  const links = root.querySelectorAll('a[href]').map((a) => a.getAttribute('href') || '');
254
309
  // Shopify и другие пишут слеши в JSON-LD как \/: без раскодирования "sameAs" с семью профилями
255
310
  // brainmd.com читался как «профилей нет», и это шло в приоритеты отчёта (12.10.2026).
@@ -268,13 +323,19 @@ export function analysePage(url, html) {
268
323
  // who wants to talk to this business can find a way to do it, and how far down it is.
269
324
  const bodyInner = body?.innerHTML || html;
270
325
  // «Получить подборку» тоже призыв (moregroupestate.ru, прогон на своих сайтах 15.10.2026: отчёт писал «на главной призыва нет»).
271
- const CONTACT_INTENT = /contact|enquir|inquir|get in touch|request a|book a|call us|consult|get a quote|free quote|order (the|an|a|your)|buy |buy now|add to (cart|bag|basket)|shop (now|all|the)|take the quiz|start the quiz|subscribe (and|&|&amp;) save|get started|start (here|free|now)|try it|check (your|a) site|audit|pricing|связ|заяв|консульт|оставить|оставьте|закаж|закажите|заказать|купить|в корзину|добавить в корзину|узнать цен|узнать стоимост|проверить сайт|начать|обсудить|обсудим|позвонить|перезвон|рассчитать|расч[её]т|записаться|запишитесь|получить (?:предложение|кп|консультац|расч|подбор|смет|прайс|цен|демо|доступ|бесплатн)|callback-|order-btn|request-btn/i;
326
+ // Список слов призыва и правило первого экрана общие с проверкой глазами клиента (ctaScan в client-eye.mjs, 15.10.2026).
272
327
  // «Обсудить проект», «Заказать», «Рассчитать стоимость» тоже призыв: cimes.ru 14.10.2026 отчёт писал «на первом экране нечего нажать».
273
328
  const hrefsLower = links.map((h) => h.toLowerCase());
329
+ // Ссылки вглубь своего раздела: у списка гайдов /gajdy/ их 135, у статьи почти нет. Нужны для «страница-список» без
330
+ // разметки CollectionPage (moregroupestate.ru, прогон на своих сайтах 15.10.2026).
331
+ const childLinks = (() => { let base = ''; try { base = new URL(url).pathname.replace(/\/?$/, '/'); } catch { return 0; } if (base === '/') return 0; const set = new Set(); for (const h of links) { try { const u = new URL(h, url); if (u.host === new URL(url).host && u.pathname.startsWith(base) && u.pathname.length > base.length + 1) set.add(u.pathname.replace(/\/$/, '')); } catch { /* битая ссылка */ } } return set.size; })();
274
332
  const telLinks = hrefsLower.filter((h) => h.startsWith('tel:')).length;
275
333
  const mailLinks = hrefsLower.filter((h) => h.startsWith('mailto:')).length;
276
- const messengerLinks = hrefsLower.filter((h) => /(wa\.me|api\.whatsapp\.com|t\.me\/|telegram\.me|m\.me\/|viber:|signal\.me)/.test(h)).length;
277
- const contactPages = hrefsLower.filter((h) => /\/(contact|contacts|contact-us|kontakt|svyaz|contacto)\/?($|[?#])/.test(h)).length;
334
+ const messengerLinks = hrefsLower.filter((h) => MESSENGER_HREF.test(h)).length;
335
+ // Ссылка «Контакты» в меню или подвале тоже путь к связи, как и адрес /kontakty или /pages/contact (etochay.ru, babynanny.ru,
336
+ // прогон на 20 сайтах 15.10.2026: отчёт писал «связаться с большинства страниц нельзя» при ссылке в подвале каждой страницы).
337
+ const contactPages = hrefsLower.filter((h) => /\/(contacts?|contact-us|kontakt\w*|svyaz\w*|obratnaya-svyaz|contacto|get-in-touch|контакты)\/?($|[?#])/.test(h)).length
338
+ + root.querySelectorAll('a').filter((x) => /^(контакты|связаться(?: с нами)?|напишите нам|обратная связь|contacts?(?: us)?|get in touch|kontakt\p{L}*)$/iu.test((x.text || '').replace(/\s+/g, ' ').trim())).length;
278
339
  const formEls = root.querySelectorAll('form');
279
340
  // Не форма заявки: поиск, окно настроек cookie и форма без полей для текста, телефона или почты (cimes.ru 14.10.2026:
280
341
  // «форма на 86 процентах страницы» оказалась окном cookie, а заявка открывается кнопкой в шапке).
@@ -319,40 +380,31 @@ export function analysePage(url, html) {
319
380
  * «без призыва на первом экране» (12.10.2026). Скрипты, стили, svg, меню и подвал не видны как
320
381
  * текст первого экрана и в счёт не идут.
321
382
  */
322
- const screenHtml = bodyInner.replace(/<!--[\s\S]*?-->/g, ' ')
323
- .replace(/<(script|style|svg|template|noscript)\b[\s\S]*?<\/\1\s*>/gi, ' ')
324
- .replace(/<(nav|footer|aside)\b[\s\S]*?<\/\1\s*>/gi, ' ');
325
- const textLen = (x) => x.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().length;
326
- const screenText = textLen(screenHtml);
327
- // Первый экран это размер окна, а не доля страницы: на 1280×800 он вмещает около 900 знаков текста. У medeq-mc.ru вводный
328
- // блок главной из четырёх абзацев, и форма телефона с кнопкой «Связаться» видна без прокрутки, а 15% текста страницы
329
- // кончались раньше неё: отчёт писал «на главной призыва нет» (экзамен по эталонам, 15.10.2026). Длинной странице 15%.
330
- const firstScreenChars = Math.max(900, Math.round(screenText * 0.15));
331
- let ctaFirstScreen = false;
332
- {
333
- let seen = 0; let from = 0;
334
- for (const m of screenHtml.matchAll(/<a[^>]*>[\s\S]*?<\/a>|<button[^>]*>[\s\S]*?<\/button>/gi)) {
335
- seen += textLen(screenHtml.slice(from, m.index)); from = m.index;
336
- if (seen > firstScreenChars) break;
337
- if (CONTACT_INTENT.test(m[0].replace(/<[^>]+>/g, ' ')) || CONTACT_INTENT.test(m[0])) { ctaFirstScreen = true; break; }
338
- }
339
- }
383
+ const ctaFirstScreen = ctaScan(bodyInner).found;
340
384
  /*
341
385
  * Видна ли цена. Цена в разметке (Offer, "price") или сумма в валюте в тексте страницы. До 0.33.4
342
386
  * строку «Цена видна» черновик писал «цены нет» не глядя, и магазин brainmd.com с ценами на каждом
343
387
  * товаре получил «no price on the pages we read» (12.10.2026).
344
388
  */
345
- const priceShown = /"@type"\s*:\s*"(?:Offer|AggregateOffer)"|"price"\s*:\s*"?\d/.test(ld)
346
- // Нулевая сумма это пустая корзина в шапке («Spend $0.00 more»), а не цена: на brainmd.com она
347
- // стояла на каждой странице, и цена «нашлась» даже на странице контактов.
348
- || /(?:[$€£₽]\s?(?:[1-9]|0[.,]\d*[1-9])|(?<![\d.,])[1-9][\d\s.,]*\s?(?:₽|руб(?:\.|лей|ля|ль)?|USD|EUR|GBP)(?![\p{L}]))/u.test(text);
389
+ /*
390
+ * «Цена видна» значит видна в тексте страницы (прогон на 20 сайтах, 15.10.2026): цена только в разметке Offer её
391
+ * человеку не показывает (refreshdekor.ru: «5 из 5», а цены только на /price), варианты бюджета в выпадающем списке
392
+ * формы («Under $100,000», moregroup.estate) не цена, а «800 р.» цена (pilatesvw.ru на Tilda).
393
+ * Нулевая сумма это пустая корзина в шапке («Spend $0.00 more», brainmd.com), а не цена.
394
+ */
395
+ const priceInMarkup = /"@type"\s*:\s*"(?:Offer|AggregateOffer)"|"price"\s*:\s*"?\d/.test(ld);
396
+ const priceShown = visiblePrice(bodyHtml);
349
397
  // Trust signals a visitor and a rating system both look for, all of them public on the page.
350
398
  const sameAs = [...ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)].flatMap((m) => m[1].match(/https?:\/\/[^"\s,\]]+/g) || []);
399
+ const textContacts = contactsInText(html);
351
400
  const trust = {
352
401
  postalAddress: /"(?:PostalAddress|streetAddress|addressLocality)"/.test(ld),
353
402
  streetAddress: /"streetAddress"\s*:\s*"[^"]+"/.test(ld),
354
- telephone: /"telephone"\s*:/.test(ld) || telLinks > 0,
355
- email: /"email"\s*:/.test(ld) || mailLinks > 0,
403
+ // Контакты текстом без ссылки тоже контакты (прогон на 20 сайтах, 15.10.2026: телефон и почта на /politika
404
+ // babynanny.ru, адрес в шапке filterosmos.ru, почта pilatesvw.ru). Ищем по всему видимому тексту, с шапкой и подвалом.
405
+ telephone: /"telephone"\s*:/.test(ld) || telLinks > 0 || Boolean(textContacts.phone),
406
+ email: /"email"\s*:/.test(ld) || mailLinks > 0 || Boolean(textContacts.email),
407
+ addressText: textContacts.address || '',
356
408
  sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
357
409
  sameAsUrls: [...new Set(sameAs)],
358
410
  // Карточки на картах и в каталогах, которые сайт показывает виджетом или ссылкой: у cimes.ru в подвале рейтинг
@@ -362,29 +414,20 @@ export function analysePage(url, html) {
362
414
  ...[...String(html).matchAll(/2gis\.ru\/[^"'\s]*?\/firm\/(\d{6,})/gi)].map((m) => `https://2gis.ru/firm/${m[1]}`),
363
415
  ])],
364
416
  // Только ссылки внутри своего сайта: «yandex.ru/support/.../about» засчитывался cimes.ru как страница о компании (14.10.2026).
365
- aboutPage: hrefsLower.some((h) => { let own = false; try { own = !/^[a-z]+:/.test(h) && !h.startsWith('//') ? true : new URL(h, url).host.replace(/^www\./, '') === new URL(url).host.replace(/^www\./, ''); } catch { own = false; } return own && /\/(about|about-us|about-company|our-story|team|company|o-nas|o-kompanii|kompaniya)\/?($|[?#])/.test(h); }),
366
- policyPages: [...new Set(hrefsLower.flatMap(policyKinds))],
417
+ // Адрес самой страницы о компании, чтобы отчёт назвал её, а не «найдено на /» (babynanny.ru, 15.10.2026).
418
+ aboutPage: (() => { const host = (u) => { try { return new URL(u, url).host.replace(/^www\./, ''); } catch { return ''; } }; const me = host(url);
419
+ const hit = root.querySelectorAll('a').map((x) => ({ h: String(x.getAttribute('href') || ''), t: (x.text || '').replace(/\s+/g, ' ').trim() })).find((x) => x.h && !/^(mailto|tel|javascript):/i.test(x.h) && !x.h.startsWith('#') && host(x.h) === me && (ABOUT_PATH.test(x.h.toLowerCase()) || ABOUT_TEXT.test(x.t)));
420
+ if (!hit) return ''; try { return decodeURI(new URL(hit.h, url).pathname) || '/'; } catch { return hit.h; } })(),
421
+ policyPages: [...new Set([...hrefsLower.flatMap(policyKinds), ...root.querySelectorAll('a').flatMap((x) => policyKindsByText(x.text))])],
367
422
  author: /"author"\s*:/.test(ld),
368
423
  };
369
- // Answer-first family, the same measures the free AI visibility check uses, so the two agree.
370
- const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
371
- // Без H1 первый абзац берём из тела страницы: иначе у главной без H1 «текста нет» (14.10.2026, medeq-mc.ru).
372
- // Вводный блок после H1 это все абзацы до первого подзаголовка, кнопки или формы. На cimes.ru (15.10.2026) ответ стоял
373
- // двумя абзацами: описание на 19 слов и сразу «Визитка от 28 000 ₽ · … Смета за 1–2 дня» в p.hero-prices, а движок
374
- // брал только первый <p> и писал «ответа с цифрой нет». Блок вместе укладывается в 20-90 слов: он и есть первый абзац.
375
- const leadZone = afterH1.split(/<h[2-6]\b|<button\b|<form\b|<a\b[^>]*class=["'][^"']*\bbtn\b/i)[0];
376
- const leadParas = [...leadZone.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => plain(m[1])).filter(Boolean);
377
- const wordsOf = (t) => (String(t).match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
378
- const leadBlock = leadParas.join(' ');
379
- const onePara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
380
- const firstPara = (leadParas.length > 1 && wordsOf(onePara) < 20 && wordsOf(leadBlock) <= 90 ? leadBlock : onePara)
381
- || ([...bodyHtml.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => plain(m[1])).find((t) => (t.match(/[\p{L}]+/gu) || []).length >= 12) || '');
424
+ // Абзац-ответ под H1: одно правило с бесплатной проверкой и проверкой глазами клиента (leadAnswer в visibility.mjs, 15.10.2026).
425
+ const lead = leadAnswer(html);
426
+ const { firstPara, firstParaWords, answerFirst } = lead;
427
+ const answerNear = lead.near;
382
428
  // Фраза, где сайт сам говорит, чем занимается: для строки «Чем занимается» при пустом описании (14.10.2026).
383
429
  const offerSentence = (String(text || '').match(/[^.!?]{0,200}(?:предлага\p{L}*|оказыва\p{L}*|занимаемся|специализир\p{L}*|we offer|we provide|we specialise|we specialize)[^.!?]{0,200}[.!?]/iu) || [''])[0].trim().slice(0, 260);
384
430
  const h2List = [...bodyHtml.matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi)].map((m) => plain(m[1]).trim()).filter(Boolean).slice(0, 10);
385
- const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
386
- // Цифра ответа это число, а не часть слова: «HTML5, CSS3» и «1С» цифрой не считаются (cimes.ru, 15.10.2026).
387
- const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && /(?<![\p{L}\d])\d+(?:[\s.,]\d+)*(?![\p{L}\d])/u.test(firstPara);
388
431
  const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
389
432
  const h3Count = (bodyHtml.match(/<h3[\s>]/gi) || []).length;
390
433
  const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
@@ -412,7 +455,8 @@ export function analysePage(url, html) {
412
455
  const citedParagraphs = paras.filter((x) => x.hrefs.some((h) => {
413
456
  try { return new URL(h, url).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
414
457
  })).length;
415
- return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: contentImgs.length + roleImgs.length, imagesNoAlt: imgsNoAlt, imagesAltMissing: imgsAltMissing, canonical, robots, viewport, og, twitter, generator, stack, htmlLang, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length - popupForms.length, formPopup: popupForms.length > 0, formFields, formDepth, ctaFirstScreen, priceShown, h2List, offerSentence, h3Count, trust };
458
+ const faqBlock = faqBlockIn(html);
459
+ return { url, title, textLang, faqBlock, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: contentImgs.length + roleImgs.length, imagesNoAlt: imgsNoAlt, imagesAltMissing: imgsAltMissing, canonical, robots, viewport, og, twitter, generator, stack, htmlLang, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, answerNear, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, textContact: Boolean(textContacts.phone || textContacts.email), forms: realForms.length - popupForms.length, formPopup: popupForms.length > 0, formFields, formDepth, ctaFirstScreen, priceShown, h2List, offerSentence, h3Count, childLinks, trust };
416
460
  }
417
461
 
418
462
  /** Карты сайта на живых коммерческих сайтах бывают вложенными десятками: у одного московского
@@ -582,7 +626,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
582
626
  // choice the owner is entitled to make, so it is reported and never scored.
583
627
  const AI_FETCHERS = ['oai-searchbot', 'chatgpt-user', 'perplexitybot', 'perplexity-user', 'claude-searchbot', 'claude-user', 'bingbot', 'duckassistbot', 'applebot'];
584
628
  const blockedFetchers = blockedAgents.filter((a) => AI_FETCHERS.includes(a));
585
- const blockedTrainers = blockedAgents.filter((a) => !AI_FETCHERS.includes(a));
629
+ // Только известные обучающие роботы ИИ. mj12bot, ahrefsbot, semrushbot, petalbot это SEO-сервисы и поиск Petal, а не ИИ:
630
+ // отчёт писал про них «эти системы не будут учиться на сайте» (прогон на 20 сайтах, 15.10.2026).
631
+ const blockedTrainers = aiTrainers(blockedAgents.filter((a) => !AI_FETCHERS.includes(a)));
586
632
  /*
587
633
  * Доступ по движкам, а не одной строкой. Раньше это была одна проверка на девять роботов, и
588
634
  * человек видел «заблокировано 2» без понимания, кто именно его не увидит. «Perplexity вас не
@@ -638,9 +684,15 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
638
684
  // считался мёртвым, а «brainmd.com.» чужим хостом (12.10.2026).
639
685
  const llmsLinks = llmsIsText ? linksInText(llms.text) : [];
640
686
  const llmsForeign = llmsLinks.filter((u) => { try { return !ownHosts.has(new URL(u).host); } catch { return false; } });
641
- checks.push(row('llms', 'geo', 'llms.txt', !llms.ok ? 'warn' : !llmsIsText ? 'bad' : llmsForeign.length > llmsLinks.length / 2 && llmsLinks.length > 3 ? 'bad' : 'ok',
687
+ // Чужие сайты в llms.txt: служебные (магазинная платформа, шрифты, Wikidata самой компании) не в счёт, у brainmd.com это
688
+ // shop.app и shopify. Остальных больше трёх: «Частично» и пояснение; меньше, «Норма» без пояснения. Раньше пояснение
689
+ // «ИИ может спутать компанию» стояло при «Норме» (moregroup.estate, прогон на своих сайтах 15.10.2026).
690
+ const INFRA = /(^|\.)(shop\.app|shopify\.(com|dev)|ucp\.dev|googleapis\.com|gstatic\.com|wikidata\.org|schema\.org|w3\.org|cloudflare\.com|jsdelivr\.net|unpkg\.com)$/i;
691
+ const llmsOther = [...new Set(llmsForeign.map((u) => { try { return new URL(u).host; } catch { return ''; } }).filter((h) => h && !INFRA.test(h)))];
692
+ const llmsStatus = !llms.ok ? 'warn' : !llmsIsText ? 'bad' : llmsForeign.length > llmsLinks.length / 2 && llmsLinks.length > 3 ? 'bad' : llmsOther.length > 3 ? 'warn' : 'ok';
693
+ checks.push(row('llms', 'geo', 'llms.txt', llmsStatus,
642
694
  !llms.ok ? 'not present' : !llmsIsText ? 'answers with an HTML page, not a text index' : `${llmsLinks.length} link(s), ${llmsForeign.length} to other hosts`,
643
- !llms.ok ? 'there is no short site map for AI agents, so they have to work through the pages themselves; generating one is a one-day job' : llmsForeign.length > 3 ? `links to ${[...new Set(llmsForeign.map((u) => new URL(u).host))].slice(0, 3).join(', ')}: an AI system may misidentify the business` : ''));
695
+ !llms.ok ? 'there is no short site map for AI agents, so they have to work through the pages themselves; generating one is a one-day job' : llmsStatus !== 'ok' && llmsOther.length ? `links to ${llmsOther.slice(0, 3).join(', ')}: an AI system may misidentify the business` : ''));
644
696
 
645
697
  // Здоровье llms.txt: мы проверяем не только то, что файл есть, но и то, что он ведёт в живые
646
698
  // страницы. Индекс для ИИ разъезжается тихо: страницу переименовали, закрыли от индексации или
@@ -766,7 +818,8 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
766
818
  // «Сейчас: 0 знаков» в задании для исполнителя читается как обрывок: человеку непонятно,
767
819
  // что описания у страницы просто не существует. Где ноль это отсутствие, пишем словами.
768
820
  checks.push(row('title', 'onpage', 'Homepage title', hp.titleLength >= 40 && hp.titleLength <= 60 ? 'ok' : hp.titleLength ? 'warn' : 'bad', hp.titleLength ? `"${hp.title}" (${hp.titleLength} chars)` : 'no title on the page', hp.titleLength < 40 ? 'short: the words that earn the click are missing' : hp.titleLength > 60 ? 'cut in results' : ''));
769
- checks.push(row('description', 'onpage', 'Homepage meta description', hp.descriptionGarbage ? 'bad' : hp.descriptionLength >= 70 && hp.descriptionLength <= 160 ? 'ok' : hp.descriptionLength ? 'warn' : 'bad', hp.descriptionGarbage ? 'contains a shortcode or encoded data' : hp.descriptionLength ? `${hp.descriptionLength} chars` : 'no meta description on the page', hp.descriptionGarbage ? 'renders as technical garbage in the snippet' : !hp.descriptionLength ? 'missing' : ''));
821
+ // Норма 50-170 знаков, как в бесплатной проверке: 161 знак шёл в письмо про Fix как «сократим до 160» (15.10.2026).
822
+ checks.push(row('description', 'onpage', 'Homepage meta description', hp.descriptionGarbage ? 'bad' : hp.descriptionLength >= 50 && hp.descriptionLength <= 170 ? 'ok' : hp.descriptionLength ? 'warn' : 'bad', hp.descriptionGarbage ? 'contains a shortcode or encoded data' : hp.descriptionLength ? `${hp.descriptionLength} chars` : 'no meta description on the page', hp.descriptionGarbage ? 'renders as technical garbage in the snippet' : !hp.descriptionLength ? 'missing' : ''));
770
823
  checks.push(row('h1', 'onpage', 'H1 on homepage', hp.h1Count === 1 ? 'ok' : hp.h1Count === 0 ? 'bad' : 'warn', hp.h1Count ? `${hp.h1Count} H1 tag(s)` : 'no H1 on the page', hp.h1Count > 1 ? 'more than one H1 dilutes the page topic' : hp.h1Count === 0 ? 'no H1' : ''));
771
824
  /*
772
825
  * Раньше canonical смотрелся только на главной, хотя десять страниц уже прочитаны. Так и
@@ -778,16 +831,16 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
778
831
  */
779
832
  checks.push(row('canonical', 'technical', 'Canonical tag', good.every((x) => x.canonical) ? 'ok' : good.some((x) => x.canonical) ? 'warn' : 'bad',
780
833
  `${good.filter((x) => x.canonical).length} of ${good.length} sampled page(s) carry a canonical tag`,
781
- good.every((x) => x.canonical) ? '' : `missing on: ${good.filter((x) => !x.canonical).map((x) => new URL(x.url).pathname).slice(0, 5).join(', ')}`));
834
+ good.every((x) => x.canonical) ? '' : `missing on: ${good.filter((x) => !x.canonical).map((x) => new URL(x.url).pathname).join(', ')}`));
782
835
  checks.push(row('title-all', 'onpage', 'Title on the sampled pages', good.every((x) => x.titleLength) ? 'ok' : 'bad',
783
836
  `${good.filter((x) => x.titleLength).length} of ${good.length} sampled page(s) carry a title`,
784
- good.every((x) => x.titleLength) ? '' : `missing on: ${good.filter((x) => !x.titleLength).map((x) => new URL(x.url).pathname).slice(0, 5).join(', ')}`));
837
+ good.every((x) => x.titleLength) ? '' : `missing on: ${good.filter((x) => !x.titleLength).map((x) => new URL(x.url).pathname).join(', ')}`));
785
838
  checks.push(row('description-all', 'onpage', 'Meta description on the sampled pages', good.every((x) => x.descriptionLength) ? 'ok' : good.some((x) => x.descriptionLength) ? 'warn' : 'bad',
786
839
  `${good.filter((x) => x.descriptionLength).length} of ${good.length} sampled page(s) carry a meta description`,
787
- good.every((x) => x.descriptionLength) ? '' : `missing on: ${good.filter((x) => !x.descriptionLength).map((x) => new URL(x.url).pathname).slice(0, 5).join(', ')}`));
840
+ good.every((x) => x.descriptionLength) ? '' : `missing on: ${good.filter((x) => !x.descriptionLength).map((x) => new URL(x.url).pathname).join(', ')}`));
788
841
  checks.push(row('schema-all', 'aeo', 'Structured data on the sampled pages', good.every((x) => x.schemaTypes.length) ? 'ok' : good.some((x) => x.schemaTypes.length) ? 'warn' : 'bad',
789
842
  `${good.filter((x) => x.schemaTypes.length).length} of ${good.length} sampled page(s) carry JSON-LD`,
790
- good.every((x) => x.schemaTypes.length) ? '' : `missing on: ${good.filter((x) => !x.schemaTypes.length).map((x) => new URL(x.url).pathname).slice(0, 5).join(', ')}`));
843
+ good.every((x) => x.schemaTypes.length) ? '' : `missing on: ${good.filter((x) => !x.schemaTypes.length).map((x) => new URL(x.url).pathname).join(', ')}`));
791
844
  checks.push(row('viewport', 'technical', 'Mobile viewport', !hp.viewport ? 'bad' : /user-scalable\s*=\s*no|maximum-scale\s*=\s*1(\.0)?\b/i.test(hp.viewport) ? 'warn' : 'ok', hp.viewport || 'missing', /user-scalable\s*=\s*no/i.test(hp.viewport) ? 'blocks zoom: bad for accessibility and mobile SEO' : ''));
792
845
  /*
793
846
  * Раньше это была одна проверка на og:title, og:image и Twitter Card. Это три разные правки в
@@ -828,24 +881,30 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
828
881
  checks.push(row('hreflang', 'technical', 'hreflang', hp.hreflang ? 'ok' : 'na', hp.hreflang ? `${hp.hreflang} alternate(s)` : 'none (single language)'));
829
882
  }
830
883
  const imgs = good.reduce((a, p) => a + p.images, 0); const noAlt = good.reduce((a, p) => a + p.imagesNoAlt, 0);
831
- if (imgs) checks.push(row('alt', 'onpage', 'Image alt text', noAlt / imgs > 0.5 ? 'bad' : noAlt / imgs > 0.2 ? 'warn' : 'ok', `${imgs - noAlt} of ${imgs} images carry alt text (${Math.round((1 - noAlt / imgs) * 100)}%) across ${good.length} sampled page(s)`));
884
+ // Одна страница с сотнями картинок без подписи (moregroup.estate /guides/: 659 из 660) делала «36% по сайту», хотя
885
+ // остальные 19 страниц в порядке (прогон на своих сайтах 15.10.2026). Такую страницу называем в самой строке.
886
+ const worst = good.filter((p) => p.imagesNoAlt > 0).sort((x, y) => y.imagesNoAlt - x.imagesNoAlt)[0];
887
+ const oneHeavy = worst && noAlt >= 10 && worst.imagesNoAlt / noAlt > 0.6 ? worst : null;
888
+ if (imgs) checks.push(row('alt', 'onpage', 'Image alt text', noAlt / imgs > 0.5 ? 'bad' : noAlt / imgs > 0.2 ? 'warn' : 'ok', `${imgs - noAlt} of ${imgs} images carry alt text (${Math.round((1 - noAlt / imgs) * 100)}%) across ${good.length} sampled page(s)${oneHeavy ? (oneHeavy.imagesNoAlt === noAlt ? `; all ${noAlt} without alt are on ${new URL(oneHeavy.url).pathname}` : `; ${oneHeavy.imagesNoAlt} of the ${noAlt} without alt are on ${new URL(oneHeavy.url).pathname}`) : ''}`));
832
889
  const multiH1 = good.filter((p) => p.h1Count > 1).length;
833
890
  // Страницы вовсе без H1 тоже считаются (14.10.2026): на medeq-mc.ru у 11 из 20 страниц H1 не было, а отчёт писал «по одному H1 на страницу».
834
891
  const noH1 = good.filter((p) => !p.h1Count).length;
835
892
  if (good.length > 1) {
836
893
  const h1Status = noH1 > good.length / 4 || multiH1 > good.length / 2 ? 'bad' : noH1 || multiH1 ? 'warn' : 'ok';
837
894
  const h1Value = noH1 || multiH1 ? [noH1 ? `${noH1} of ${good.length} sampled pages carry no H1` : '', multiH1 ? `${multiH1} of ${good.length} sampled pages carry more than one H1` : ''].filter(Boolean).join('; ') : 'one H1 on every sampled page';
838
- checks.push(row('h1-sample', 'onpage', 'Heading structure across sample', h1Status, h1Value));
895
+ checks.push(row('h1-sample', 'onpage', 'Heading structure across the sample', h1Status, h1Value));
839
896
  }
840
897
  const titles = new Map(); for (const p of good) titles.set(p.title, (titles.get(p.title) || 0) + 1);
841
898
  const dupTitles = [...titles].filter(([, n]) => n > 1);
842
- if (good.length > 1) checks.push(row('dup-titles', 'onpage', 'Duplicate titles in sample', dupTitles.length ? 'warn' : 'ok', dupTitles.length ? `${dupTitles.length} title(s) shared by several pages, e.g. "${dupTitles[0][0]}"` : 'every sampled page has its own title'));
899
+ if (good.length > 1) checks.push(row('dup-titles', 'onpage', 'Duplicate titles in the sample', dupTitles.length ? 'warn' : 'ok', dupTitles.length ? `${dupTitles.length} title(s) shared by several pages, e.g. "${dupTitles[0][0]}"` : 'every sampled page has its own title'));
843
900
  // Список статей, отзывы с виджетом, рубрики короткие по устройству: их не считаем тонкими (14.10.2026, neronov.pro).
844
901
  const LISTY = /\/(articles|blog|news|novosti|stati|otzyvy|reviews|testimonials|category|tag|page\/\d+)\/?$/i;
845
902
  const thin = good.filter((p) => p.words < 300 && !LISTY.test(new URL(p.url).pathname));
903
+ // Короткие страницы-списки не считаем, но называем: «4 из 20» при пяти коротких в таблице читалось как ошибка (smirnov-xo.ru, 15.10.2026).
904
+ const listyShort = good.filter((p) => p.words < 300 && LISTY.test(new URL(p.url).pathname));
846
905
  // Подпись нейтральная: при «ok» строка уходит в «сильные стороны», и «Thin pages in sample: median 1370 words» там
847
906
  // читалась как беда (oper-stack.com, прогон на своих сайтах 15.10.2026).
848
- if (good.length > 1) checks.push(row('thin', 'content', 'Page length in sample', thin.length > good.length / 2 ? 'bad' : thin.length ? 'warn' : 'ok', thin.length ? `${thin.length} of ${good.length} sampled pages under 300 words` : `median ${median(good.map((p) => p.words))} words per page`));
907
+ if (good.length > 1) checks.push(row('thin', 'content', 'Page length in sample', thin.length > good.length / 2 ? 'bad' : thin.length ? 'warn' : 'ok', thin.length ? `${thin.length} of ${good.length} sampled pages under 300 words${listyShort.length ? `, list pages not counted: ${listyShort.map((p) => { try { return decodeURI(new URL(p.url).pathname); } catch { return p.url; } }).join(', ')}` : ''}` : `median ${median(good.map((p) => p.words))} words per page`));
849
908
  // «shop» это витрина, а не служебная страница: cimes.ru/solutions/shop/ продающий каталог (14.10.2026). Только целый сегмент пути.
850
909
  // «compare» не берём: у oper-stack.com /compare/ это раздел сравнений на тысячу слов, а не сравнение товаров магазина
851
910
  // (прогон на своих сайтах 15.10.2026). По адресу их не различить, а служебная страница сравнения в карте сайта редкость.
@@ -857,14 +916,20 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
857
916
  checks.push(row('utility', 'technical', 'Utility pages in the sitemap', 'warn', utility.length === 1 ? `1 URL: ${new URL(utility[0]).pathname}` : `${utility.length} URL(s) such as ${new URL(utility[0]).pathname}`, `${kinds.join(', ') || 'utility pages'} add noise to the index`));
858
917
  }
859
918
  const redirected = sample.filter((p) => p.redirect).length; const broken = sample.filter((p) => p.status && p.status >= 400).length;
860
- if (sitemapPages.length) checks.push(row('sitemap-health', 'technical', 'Sitemap URLs that answer 200', broken ? 'bad' : redirected ? 'warn' : 'ok', `${sample.length - redirected - broken} of ${sample.length} sampled URLs answer 200${redirected ? `, ${redirected} redirect` : ''}${broken ? `, ${broken} broken` : ''}`));
919
+ if (sitemapPages.length) checks.push(row('sitemap-health', 'technical', 'Sitemap URLs and their answers', broken ? 'bad' : redirected ? 'warn' : 'ok', `${sample.length - redirected - broken} of ${sample.length} sampled URLs answer 200${redirected ? `, ${redirected} redirect` : ''}${broken ? `, ${broken} broken` : ''}`));
861
920
  if (good.length) {
862
- const af = good.filter((p) => p.answerFirst).length;
863
- checks.push(row('answer-first', 'aeo', 'Answer-first opening paragraph', af === good.length ? 'ok' : af ? 'warn' : 'bad', `${af} of ${good.length} sampled page(s) open with 20 to 90 words carrying a figure, right after the H1`, af === good.length ? '' : 'that paragraph is the one an answer engine lifts'));
864
- const structured = good.filter((p) => p.h2Count >= 3).length;
865
- checks.push(row('sections', 'aeo', 'Section structure', structured === good.length ? 'ok' : structured ? 'warn' : 'bad', `${structured} of ${good.length} sampled page(s) carry three or more H2 sections`, structured === good.length ? '' : 'engines quote sections, not walls of text'));
921
+ // Юридические страницы и страницы-списки ответа с цифрой не требуют, как и в задачах (pagekind.mjs): «нет на 3 из 18», а
922
+ // в задаче одна страница, спорило с собой (babynanny.ru, 16.10.2026). Разделы считаем только у длинных страниц с текстом.
923
+ const afBase = good.filter((p) => !isLegalPage(p) && !isListPage(p));
924
+ const afPool = afBase.length ? afBase : good;
925
+ const af = afPool.filter((p) => p.answerFirst).length;
926
+ checks.push(row('answer-first', 'aeo', 'Answer-first opening paragraph', af === afPool.length ? 'ok' : af ? 'warn' : 'bad', `${af} of ${afPool.length} sampled page(s) open with 20 to 90 words carrying a figure, right after the H1${afPool.length < good.length ? ` (legal and list pages not counted: ${good.filter((p) => !afPool.includes(p)).map((p) => { try { return decodeURI(new URL(p.url).pathname); } catch { return p.url; } }).join(', ')})` : ''}`, af === good.length ? '' : 'that paragraph is the one an answer engine lifts'));
927
+ const secPool = good.filter((p) => !isLegalPage(p) && !isListPage(p) && (p.words || 0) >= 600);
928
+ const structured = secPool.filter((p) => p.h2Count >= 3).length;
929
+ if (!secPool.length) checks.push(row('sections', 'aeo', 'Section structure', 'ok', 'no long text page in the sample needs splitting (legal, list and pages under 600 words are not counted)'));
930
+ else checks.push(row('sections', 'aeo', 'Section structure', structured === secPool.length ? 'ok' : structured ? 'warn' : 'bad', `${structured} of ${secPool.length} long text ${secPool.length === 1 ? 'page' : 'pages'} in the sample ${structured === 1 ? 'carries' : 'carry'} three or more H2 sections (pages of 600 words and more; legal and list pages not counted)`, structured === secPool.length ? '' : 'engines quote sections, not walls of text'));
866
931
  const withTables = good.filter((p) => p.tables > 0).length;
867
- checks.push(row('tables', 'aeo', 'Tables in the content', withTables ? 'ok' : 'warn', withTables ? `${withTables} of ${good.length} sampled page(s) use a table` : 'no table on the sampled pages', withTables ? '' : 'tables are the second most quoted format after the opening paragraph'));
932
+ checks.push(row('tables', 'aeo', 'Tables in the content', withTables ? 'ok' : 'warn', withTables ? `${withTables} of ${good.length} sampled page(s) use a table` : 'no table on the sampled pages', withTables ? 'a table is needed only where there is something to compare, not on every page' : 'tables are the second most quoted format after the opening paragraph'));
868
933
  /*
869
934
  * Источник у цифры. Правило не наше: в работе Aggarwal и др. (KDD 2024, 10 000 запросов,
870
935
  * девять способов правки) «назвать источник» дало самый большой прирост видимости из всех
@@ -884,11 +949,12 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
884
949
  * проверке различить это нельзя.
885
950
  */
886
951
  const SELF_SOURCED = /^(Product|Offer|Service|AggregateOffer|SoftwareApplication|Course|Event|JobPosting|Recipe|RealEstateListing)$/;
887
- const judged = good.filter((p) => p.figureParagraphs > 0 && !p.schemaTypes.some((t) => SELF_SOURCED.test(t)));
952
+ // Юридические страницы и контакты не судим: там номера пунктов, адреса и телефоны, а не статистика (16.10.2026).
953
+ const judged = good.filter((p) => p.figureParagraphs > 0 && !p.schemaTypes.some((t) => SELF_SOURCED.test(t)) && !isLegalPage(p) && !isContactPage(p));
888
954
  if (judged.length) {
889
955
  const sourced = judged.filter((p) => p.sourcePhrases > 0 || p.citedParagraphs > 0).length;
890
956
  checks.push(row('sources', 'content', 'Sources named in the text', sourced === judged.length ? 'ok' : sourced ? 'warn' : 'bad',
891
- `${sourced} of ${judged.length} page(s) carrying figures name where the figure comes from, in words or as an outbound link beside it${judged.length < good.length ? ` (${good.length - judged.length} page(s) not judged: no figures, or a product or service page whose own price is the source)` : ''}`,
957
+ `${sourced} of ${judged.length} page(s) carrying figures name where the figure comes from, in words or as an outbound link beside it${judged.length < good.length ? ` (${good.length - judged.length} page(s) not judged: no figures, a legal or contact page, or a product or service page whose own price is the source)` : ''}`,
892
958
  sourced === judged.length ? '' : 'naming the source was the single strongest tested way to be quoted (Aggarwal et al., KDD 2024: +30 to +40%); it matters most on pages that are not already ranking first, which public signals cannot tell us'));
893
959
  } else {
894
960
  checks.push(row('sources', 'content', 'Sources named in the text', 'na',
@@ -914,8 +980,8 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
914
980
  const kindCount = (re) => sitemapPages.filter((u) => { try { const p = new URL(u).pathname; return re.test(p) && ARTICLE.test(p); } catch { return false; } }).length;
915
981
  const notSampled = { blog: kindCount(/\/(blogs?|stati|statya|article|articles|post|posts|journal|zhurnal|insights|guides?)\//i), news: kindCount(/\/(news|novosti|press|media)\//i), cases: kindCount(/\/(kejsy?|cases?|portfolio)\//i) };
916
982
  const notSampledLine = Object.entries(notSampled).filter(([, n]) => n).map(([k, n]) => `${k} ${n}`).join(', ');
917
- if (good.length > 2 && !articleLike.length) checks.push(row('dates', 'content', 'Publication dates exposed', 'na', notSampledLine ? `no articles, news or cases in the sample (not sampled from the sitemap: ${notSampledLine}): nothing to date here` : 'no articles, news or cases in the sample: nothing to date'));
918
- else if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', (dated >= DATES_OK_MIN || dated >= good.length / 2) && seen >= dated / 2 ? 'ok' : 'warn', dated ? `${dated} of ${good.length} sampled pages carry a date in the markup; on ${seen} of them the same date is also visible on the page` : 'no article dates in the sample: answer engines cannot tell what is current'));
983
+ if (good.length > 2 && !articleLike.length) checks.push(row('dates', 'content', 'Publication dates on the page', 'na', notSampledLine ? `no articles, news or cases in the sample (not sampled from the sitemap: ${notSampledLine}): nothing to date here` : 'no articles, news or cases in the sample: nothing to date'));
984
+ else if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates on the page', (dated >= DATES_OK_MIN || dated >= good.length / 2) && seen >= dated / 2 ? 'ok' : 'warn', dated ? `${dated} of ${good.length} sampled pages carry a date in the markup; on ${seen} of them the same date is also visible on the page` : 'no article dates in the sample: answer engines cannot tell what is current'));
919
985
  if (/wordpress/i.test(hp?.stack || '')) { const x = await get(`${siteOrigin}/xmlrpc.php`, { method: 'HEAD' }); if (x.status === 405 || x.status === 200) checks.push(row('xmlrpc', 'technical', 'xmlrpc.php', 'warn', `answers ${x.status}`, 'pingback endpoint open: attack surface with no SEO value')); }
920
986
 
921
987
  // ---- Off-page and trust ----------------------------------------------------------------
@@ -971,12 +1037,12 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
971
1037
  const sameAsAll = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].join(' ');
972
1038
  const listingsMissing = [...new Set(pagesForTrust.flatMap((p) => p.trust?.listings || []))].filter((u) => { const id = (u.match(/(\d{6,})/) || [])[1]; return id && !sameAsAll.includes(id); }).slice(0, 3);
973
1039
  const messengerNames = [...new Set(messengersInSchema.map((h) => (/t\.me|telegram/i.test(h) ? 'Telegram' : /whatsapp|wa\.me/i.test(h) ? 'WhatsApp' : /viber/i.test(h) ? 'Viber' : h)))];
974
- checks.push(row('trust-profiles', 'offpage', 'Profiles the site claims elsewhere', profiles.length >= 2 ? 'ok' : profiles.length ? 'warn' : 'bad', profiles.length ? profileLine(profiles.slice(0, 5), lang, profiles.length) : listingsMissing.length ? `${messengerNames.length ? `only messengers in sameAs (${messengerNames.join(', ')}); ` : ''}a listing shown on the site is missing from the markup: ${listingsMissing.join(', ')}` : messengerNames.length ? `only messengers in sameAs (${messengerNames.join(', ')}): no profile on any platform` : 'no sameAs links: the site claims no profile anywhere else', profiles.length ? '' : 'a free off-site signal: links to your own profiles in the markup'));
1040
+ checks.push(row('trust-profiles', 'offpage', 'Profiles the site claims elsewhere', profiles.length >= 2 ? 'ok' : profiles.length ? 'warn' : 'bad', profiles.length ? profileLine(profiles, lang) : listingsMissing.length ? `${messengerNames.length ? `only messengers in sameAs (${messengerNames.join(', ')}); ` : ''}a listing shown on the site is missing from the markup: ${listingsMissing.join(', ')}` : messengerNames.length ? `only messengers in sameAs (${messengerNames.join(', ')}): no profile on any platform` : 'no sameAs links: the site claims no profile anywhere else', profiles.length ? '' : 'a free off-site signal: links to your own profiles in the markup'));
975
1041
  if (!profiles.length && messengerNames.length) checks[checks.length - 1].messengersOnly = messengerNames;
976
1042
  if (!profiles.length && listingsMissing.length) { checks[checks.length - 1].listings = listingsMissing; checks[checks.length - 1].status = 'warn'; }
977
- const about = anyTrust((p) => p.trust?.aboutPage);
1043
+ const about = (pagesForTrust.find((p) => p.trust?.aboutPage) || {}).trust?.aboutPage || '';
978
1044
  // Страницы о компании нет: это «Проблема», а не «Частично» (cimes.ru, 14.10.2026).
979
- checks.push(row('trust-about', 'offpage', 'A page that says who is behind the site', about ? 'ok' : 'bad', about ? 'linked from the sampled pages' : 'no about or team page is linked'));
1045
+ checks.push(row('trust-about', 'offpage', 'A page that says who is behind the site', about ? 'ok' : 'bad', about ? `linked from the sampled pages: ${about}` : 'no about or team page is linked'));
980
1046
  const policies = [...new Set(pagesForTrust.flatMap((p) => p.trust?.policyPages || []))];
981
1047
  checks.push(row('trust-policies', 'offpage', 'Privacy and terms', policies.length >= 2 ? 'ok' : policies.length ? 'warn' : 'bad', policies.length ? policies.join(', ') : 'neither a privacy policy nor terms are linked'));
982
1048
 
@@ -984,7 +1050,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
984
1050
  // Measured from the pages, not from analytics: whether a visitor who wants to talk can, and how
985
1051
  // far down the page the way to do it sits. Behaviour data, when the client grants it, adds to this.
986
1052
  const withForm = pagesForTrust.filter((p) => p.forms > 0);
987
- const reachable = pagesForTrust.filter((p) => p.forms > 0 || p.telLinks || p.mailLinks || p.messengerLinks || p.contactPages);
1053
+ const reachable = pagesForTrust.filter((p) => p.forms > 0 || p.telLinks || p.mailLinks || p.messengerLinks || p.contactPages || p.textContact);
988
1054
  checks.push(row('conv-contact-path', 'conversion', 'A way to get in touch', reachable.length === pagesForTrust.length ? 'ok' : reachable.length ? 'warn' : 'bad', `${reachable.length} of ${pagesForTrust.length} sampled page(s) offer a form, a contact link or a contact page`));
989
1055
  const withCta = pagesForTrust.filter((p) => p.ctaFirstScreen);
990
1056
  checks.push(row('conv-cta', 'conversion', 'A call to action in the first screen', withCta.length >= pagesForTrust.length * 0.8 ? 'ok' : withCta.length ? 'warn' : 'bad', `${withCta.length} of ${pagesForTrust.length} sampled page(s) put one in the first screen`, withCta.length === pagesForTrust.length ? '' : 'a reader who is convinced at the top should not have to hunt'));
@@ -996,8 +1062,12 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
996
1062
  checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', md < 50 || ctaShare >= 0.8 ? 'ok' : md < 80 ? 'warn' : 'bad', `median ${md}% of the way down the page, measured on ${depths.length} page(s)`, ctaShare >= 0.8 && md >= 50 ? 'the first screen already has a button that leads to it' : md >= 80 ? 'on a long page almost nobody scrolls that far, least of all on a phone' : ''));
997
1063
  } else if (pagesForTrust.some((p) => p.formPopup) && withCta.length) {
998
1064
  // Заявка во всплывающем окне по кнопке: глубины нет, есть кнопка на первом экране.
999
- const popup = pagesForTrust.filter((p) => p.formPopup).length;
1000
- checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', withCta.length >= pagesForTrust.length * 0.8 ? 'ok' : 'warn', `the form opens in a pop-up from a button on ${popup} of ${pagesForTrust.length} sampled page(s); the button is in the first screen on ${withCta.length}`));
1065
+ // Поднимать нечего, если там, где форма есть, кнопка к ней на первом экране: страницы без формы это другая строка
1066
+ // («Способ связаться»). Раньше статус шёл от доли кнопок по всей выборке, и задача «подними форму» уходила владельцу,
1067
+ // у которого форма во всплывающем окне по кнопке «Запись» на первом экране (pilatesvw.ru, 15.10.2026).
1068
+ const popupPages = pagesForTrust.filter((p) => p.formPopup);
1069
+ const popup = popupPages.length; const popupCta = popupPages.filter((p) => p.ctaFirstScreen).length;
1070
+ checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', popupCta === popup ? 'ok' : 'warn', `the form opens in a pop-up from a button on ${popup} of ${pagesForTrust.length} sampled page(s); on ${popupCta} of them the button is in the first screen`));
1001
1071
  } else {
1002
1072
  // Формы нет, но заявки идут через мессенджеры: это не провал, а другой способ (14.10.2026).
1003
1073
  const viaMsg = pagesForTrust.filter((p) => p.messengerLinks).length;
@@ -1007,7 +1077,8 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
1007
1077
  const fields = withForm.filter((p) => p.formDepth !== null && p.formDepth !== undefined).map((p) => p.formFields).filter((n) => n > 0);
1008
1078
  if (fields.length) { const mf = median(fields); checks.push(row('conv-form-fields', 'conversion', 'What the form asks for', mf <= 4 ? 'ok' : mf <= 6 ? 'warn' : 'bad', (fields.length === 1 ? `${mf} field(s) in the only enquiry form found in the sample` : `median ${mf} field(s) before a visitor can send anything`), mf > 4 ? 'every extra field costs replies, and a phone number costs the most' : '')); }
1009
1079
  const msg = pagesForTrust.filter((p) => p.messengerLinks > 0);
1010
- checks.push(row('conv-messenger', 'conversion', 'A messenger link', msg.length ? 'ok' : 'warn', msg.length ? `on ${msg.length} of ${pagesForTrust.length} sampled page(s)` : 'none on the sampled pages', msg.length ? '' : 'for an international audience reading on a phone, one messenger link is usually worth more than a form'));
1080
+ // Мессенджер на 2 из 20 страниц это не «Норма»: с остальных его не найти (oper-stack.ru, 15.10.2026).
1081
+ checks.push(row('conv-messenger', 'conversion', 'A messenger link', msg.length >= pagesForTrust.length * 0.8 ? 'ok' : 'warn', msg.length ? `on ${msg.length} of ${pagesForTrust.length} sampled page(s)` : 'none on the sampled pages', msg.length ? '' : 'for an international audience reading on a phone, one messenger link is usually worth more than a form'));
1011
1082
 
1012
1083
  // Оценки считаются до перевода: язык на цифры не влияет.
1013
1084
  // Сколько текста не видно без выполнения скриптов. Меряется настоящим браузером и только по
@@ -1089,7 +1160,8 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
1089
1160
  rows: ['Система управления сайтом (CMS)', 'Страниц в карте сайта', 'Язык', 'Чем занимается'],
1090
1161
  partial: (n, read, total) => `не меньше ${n}: прочитано ${read} из ${total} файлов карты, остальные не успели`,
1091
1162
  site: 'Сам сайт', free: 'Бесплатно, без аккаунта',
1092
- siteDetail: (n, viaLinks) => `robots.txt, карты сайта, llms.txt, главная и ${n} ${pagesRu(n)} выборки, прочитанные так же, как их читает любой посетитель${viaLinks ? `; ${viaLinks} из них найдены по ссылкам с главной, потому что в карте сайта их слишком мало` : ''}`,
1163
+ // Главная входит в выборку: «главная и 18 страниц» читалось как 19 (проверяющий, babynanny.ru, 15.10.2026).
1164
+ siteDetail: (n, viaLinks) => `robots.txt, карты сайта, llms.txt и ${n} ${pagesRu(n)} выборки вместе с главной, прочитанные так же, как их читает любой посетитель${viaLinks ? `; ${viaLinks} из них ${viaLinks % 10 === 1 && viaLinks % 100 !== 11 ? 'найдена' : 'найдены'} по ссылкам с главной, потому что в карте сайта нашлось меньше адресов, чем нужно для выборки` : ''}`,
1093
1165
  paid: 'Платный сервис, по желанию', paidDetail: 'только для справки, ни в одну оценку не входит',
1094
1166
  limitations: ['Google Search Console и Яндекс Вебмастер: показов, кликов и запросов нет', 'Аналитика: данных о посещениях и поведении нет', 'Сервисы ключевых слов и ссылок: в проверке по публичным сигналам не используются'],
1095
1167
  fix: 'Исправить',
@@ -1100,7 +1172,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
1100
1172
  rows: ['CMS / stack', 'Pages in sitemap', 'Language', 'What is sold'],
1101
1173
  partial: (n, read, total) => `at least ${n}: ${read} of ${total} sitemap files were read, the rest were not reached`,
1102
1174
  site: 'The site itself', free: 'Free, no account needed',
1103
- siteDetail: (n, viaLinks) => `robots.txt, the sitemaps, llms.txt, the homepage and ${n} sampled page(s), read exactly as any visitor reads them${viaLinks ? `; ${viaLinks} of the pages were found by following links from the homepage, because the sitemap listed too few` : ''}`,
1175
+ siteDetail: (n, viaLinks) => `robots.txt, the sitemaps, llms.txt and ${n} sampled ${n === 1 ? 'page' : 'pages'} including the homepage, read exactly as any visitor reads them${viaLinks ? `; ${viaLinks} of the pages were found by following links from the homepage, because the sitemap listed too few` : ''}`,
1104
1176
  paid: 'Paid tool, optional', paidDetail: 'reported for context only, not counted in any score',
1105
1177
  limitations: ['Google Search Console: no impressions, clicks or query data', 'Analytics: no traffic or behaviour data', 'Keyword and backlink tools: not used in the external tier'],
1106
1178
  fix: 'Fix',
@@ -1111,7 +1183,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
1111
1183
  const criticalShown = ru ? localised.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' })) : critical;
1112
1184
 
1113
1185
  return {
1114
- meta: { site: home.final, host, reachable: homeAnswered, homeStatus: home.response.status || 0, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, tier, auditType: ['9', '29', 'watch', 'free'].includes(String(tier)) ? (lang === 'ru' ? 'Замер по публичным сигналам' : 'External check (no Search Console or analytics access)') : (lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)'), lang, language: hp ? (hp.htmlLang || '') : '' },
1186
+ meta: { site: home.final, host, reachable: homeAnswered, homeStatus: home.response.status || 0, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, tier, auditType: ['9', '29', 'watch', 'free'].includes(String(tier)) ? (lang === 'ru' ? 'Замер по публичным сигналам' : 'External check (no Search Console or analytics access)') : (lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)'), lang, language: hp ? (hp.textLang === 'ru' && !/^ru/i.test(hp.htmlLang || '') ? 'ru' : (hp.htmlLang || hp.textLang || '')) : '' }, // по тексту только в сторону русского: латиница в коде страницы давала «английский» русским сайтам (babynanny.ru, 16.10.2026)
1115
1187
  client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: preparedBy || process.env.OPERSTACK_PREPARED_BY || 'OperStack' },
1116
1188
  overall,
1117
1189
  reportScore,
@@ -1142,7 +1214,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
1142
1214
  reading,
1143
1215
  /* Сырьё для готовых файлов: профили, которые сайт сам про себя заявляет, и есть ли контакты. */
1144
1216
  entity: { profiles: profileUrls, hosts: profiles, telephone: Boolean(hp?.trust?.telephone), email: Boolean(hp?.trust?.email) },
1145
- sample: sample.map((p) => p.title !== undefined ? { url: p.url, title: p.title, description: p.description, h1: p.h1, firstPara: p.firstPara, words: p.words, h1Count: p.h1Count, linkCount: Array.isArray(p.links) ? p.links.length : null, images: p.images, imagesNoAlt: p.imagesNoAlt, imagesAltMissing: p.imagesAltMissing, schemaTypes: p.schemaTypes, answerFirst: p.answerFirst, sourcePhrases: p.sourcePhrases, citedParagraphs: p.citedParagraphs, figureParagraphs: p.figureParagraphs, h2Count: p.h2Count, tables: p.tables, firstParaWords: p.firstParaWords, dates: p.dates, priceShown: p.priceShown, ctaFirstScreen: p.ctaFirstScreen, h2List: p.h2List, offerSentence: p.offerSentence, h3Count: p.h3Count } : p),
1217
+ sample: sample.map((p) => p.title !== undefined ? { url: p.url, title: p.title, description: p.description, h1: p.h1, firstPara: p.firstPara, words: p.words, h1Count: p.h1Count, linkCount: Array.isArray(p.links) ? p.links.length : null, images: p.images, imagesNoAlt: p.imagesNoAlt, imagesAltMissing: p.imagesAltMissing, schemaTypes: p.schemaTypes, answerFirst: p.answerFirst, answerNear: p.answerNear || "", sourcePhrases: p.sourcePhrases, citedParagraphs: p.citedParagraphs, figureParagraphs: p.figureParagraphs, h2Count: p.h2Count, tables: p.tables, firstParaWords: p.firstParaWords, dates: p.dates, priceShown: p.priceShown, ctaFirstScreen: p.ctaFirstScreen, faqBlock: Boolean(p.faqBlock), addressText: p.trust?.addressText || '', contactReach: Boolean(p.forms > 0 || p.telLinks || p.mailLinks || p.messengerLinks || p.contactPages || p.textContact), formDepth: p.formDepth ?? null, formFields: p.formFields ?? null, formPopup: Boolean(p.formPopup), h2List: p.h2List, offerSentence: p.offerSentence, h3Count: p.h3Count, childLinks: p.childLinks } : p),
1146
1218
  notes,
1147
1219
  };
1148
1220
  }