@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/bin/audit.mjs +2 -2
- package/package.json +1 -1
- package/src/client-eye.mjs +78 -28
- package/src/collect.mjs +153 -81
- package/src/fix.mjs +124 -5
- package/src/foundation.mjs +2 -1
- package/src/i18n.mjs +49 -25
- package/src/index.mjs +1 -1
- package/src/narrative.mjs +56 -25
- package/src/niche.mjs +13 -12
- package/src/pagekind.mjs +18 -1
- package/src/presentation.mjs +3 -2
- package/src/prompts.mjs +161 -40
- package/src/render.mjs +61 -30
- package/src/rendered.mjs +6 -2
- package/src/tasks-pdf.mjs +10 -5
- package/src/verify.mjs +26 -13
- package/src/visibility.mjs +137 -45
- package/src/vy.mjs +13 -0
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(/ | /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(/ | /g, ' ').replace(/+|+/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(/ | /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
|
-
|
|
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
|
-
|
|
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) =>
|
|
277
|
-
|
|
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
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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
|
-
//
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' :
|
|
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
|
-
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
const
|
|
865
|
-
|
|
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
|
-
|
|
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
|
|
918
|
-
else if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates
|
|
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
|
|
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 =
|
|
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 ?
|
|
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
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|