@operstack/audit 0.36.2 → 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/README.md +10 -0
- package/bin/audit.mjs +2 -2
- package/package.json +5 -3
- package/src/client-eye.mjs +389 -0
- package/src/collect.mjs +171 -85
- package/src/fix.mjs +124 -5
- package/src/foundation.mjs +2 -1
- package/src/i18n.mjs +50 -25
- package/src/index.mjs +2 -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 +163 -42
- 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/test-agency-ids.mjs +0 -48
- package/src/test-agency.mjs +0 -158
- package/src/test-agent-surface.mjs +0 -46
- package/src/test-answer-first.mjs +0 -27
- package/src/test-blocked.mjs +0 -100
- package/src/test-crawl-links.mjs +0 -87
- package/src/test-demand.mjs +0 -243
- package/src/test-etalon.mjs +0 -42
- package/src/test-fix.mjs +0 -115
- package/src/test-flaky-files.mjs +0 -81
- package/src/test-foundation.mjs +0 -126
- package/src/test-handover.mjs +0 -148
- package/src/test-i18n.mjs +0 -68
- package/src/test-narrative.mjs +0 -117
- package/src/test-overall.mjs +0 -236
- package/src/test-planted.mjs +0 -114
- package/src/test-presentation.mjs +0 -67
- package/src/test-prompts.mjs +0 -142
- package/src/test-prospect-count.mjs +0 -12
- package/src/test-redirect-host.mjs +0 -63
- package/src/test-rendered.mjs +0 -136
- package/src/test-report-wording.mjs +0 -35
- package/src/test-ru-report.mjs +0 -95
- package/src/test-ru-sources.mjs +0 -41
- package/src/test-sample-fairness.mjs +0 -50
- package/src/test-sample.mjs +0 -91
- package/src/test-scores.mjs +0 -75
- package/src/test-shopify-store.mjs +0 -159
- package/src/test-sitemap.mjs +0 -91
- package/src/test-unmeasured.mjs +0 -173
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 [];
|
|
@@ -194,7 +247,9 @@ export function analysePage(url, html) {
|
|
|
194
247
|
// H1 из шаблонов Vue и подобных (v-if, :title, {{ }} или внутри <template>) человек не видит: не считаем (14.10.2026, brainmd.com).
|
|
195
248
|
const inTemplate = (el) => { for (let n = el.parentNode; n; n = n.parentNode) { const tag = String(n.rawTagName || n.tagName || '').toLowerCase(); if (tag === 'template' || tag === 'script') return true; } return false; };
|
|
196
249
|
const framework = (el) => Object.keys(el.attributes || {}).some((k) => /^(v-|:|@|x-)/.test(k)) || /\{\{|\}\}/.test(el.text);
|
|
197
|
-
|
|
250
|
+
// Тот же H1 дважды это копия блока для окна быстрого просмотра или мобильной вёрстки, видимый один (brainmd.com,
|
|
251
|
+
// товары Shopify: «Omega-3 Power» в карточке и в quick-view; отчёт писал «больше одного H1 на 7 из 20», 14.10.2026).
|
|
252
|
+
const h1s = [...new Set(root.querySelectorAll('h1').filter((h) => !inTemplate(h) && !framework(h)).map((h) => h.text.trim().replace(/\s+/g, ' ')).filter(Boolean))];
|
|
198
253
|
const imgs = root.querySelectorAll('img');
|
|
199
254
|
// Значки оформления (svg с пустым alt) и счётчики-пиксели не картинки содержания: пустой alt у них правильный.
|
|
200
255
|
// Фото, выведенные блоком с role="img" и aria-label (Tilda), считаем подписанными (14.10.2026).
|
|
@@ -248,6 +303,8 @@ export function analysePage(url, html) {
|
|
|
248
303
|
// Слова считаем по содержимому без меню и подвала: иначе страница из одного абзаца в большом
|
|
249
304
|
// шаблоне выглядит на три тысячи слов и проверка на пустые страницы не значит ничего.
|
|
250
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' : ''; })();
|
|
251
308
|
const links = root.querySelectorAll('a[href]').map((a) => a.getAttribute('href') || '');
|
|
252
309
|
// Shopify и другие пишут слеши в JSON-LD как \/: без раскодирования "sameAs" с семью профилями
|
|
253
310
|
// brainmd.com читался как «профилей нет», и это шло в приоритеты отчёта (12.10.2026).
|
|
@@ -265,13 +322,20 @@ export function analysePage(url, html) {
|
|
|
265
322
|
// Conversion path, read from the page itself. No analytics is needed to see whether a visitor
|
|
266
323
|
// who wants to talk to this business can find a way to do it, and how far down it is.
|
|
267
324
|
const bodyInner = body?.innerHTML || html;
|
|
268
|
-
|
|
325
|
+
// «Получить подборку» тоже призыв (moregroupestate.ru, прогон на своих сайтах 15.10.2026: отчёт писал «на главной призыва нет»).
|
|
326
|
+
// Список слов призыва и правило первого экрана общие с проверкой глазами клиента (ctaScan в client-eye.mjs, 15.10.2026).
|
|
269
327
|
// «Обсудить проект», «Заказать», «Рассчитать стоимость» тоже призыв: cimes.ru 14.10.2026 отчёт писал «на первом экране нечего нажать».
|
|
270
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; })();
|
|
271
332
|
const telLinks = hrefsLower.filter((h) => h.startsWith('tel:')).length;
|
|
272
333
|
const mailLinks = hrefsLower.filter((h) => h.startsWith('mailto:')).length;
|
|
273
|
-
const messengerLinks = hrefsLower.filter((h) =>
|
|
274
|
-
|
|
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;
|
|
275
339
|
const formEls = root.querySelectorAll('form');
|
|
276
340
|
// Не форма заявки: поиск, окно настроек cookie и форма без полей для текста, телефона или почты (cimes.ru 14.10.2026:
|
|
277
341
|
// «форма на 86 процентах страницы» оказалась окном cookie, а заявка открывается кнопкой в шапке).
|
|
@@ -316,37 +380,31 @@ export function analysePage(url, html) {
|
|
|
316
380
|
* «без призыва на первом экране» (12.10.2026). Скрипты, стили, svg, меню и подвал не видны как
|
|
317
381
|
* текст первого экрана и в счёт не идут.
|
|
318
382
|
*/
|
|
319
|
-
const
|
|
320
|
-
.replace(/<(script|style|svg|template|noscript)\b[\s\S]*?<\/\1\s*>/gi, ' ')
|
|
321
|
-
.replace(/<(nav|footer|aside)\b[\s\S]*?<\/\1\s*>/gi, ' ');
|
|
322
|
-
const textLen = (x) => x.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().length;
|
|
323
|
-
const screenText = textLen(screenHtml);
|
|
324
|
-
const firstScreenChars = Math.max(300, Math.round(screenText * 0.15));
|
|
325
|
-
let ctaFirstScreen = false;
|
|
326
|
-
{
|
|
327
|
-
let seen = 0; let from = 0;
|
|
328
|
-
for (const m of screenHtml.matchAll(/<a[^>]*>[\s\S]*?<\/a>|<button[^>]*>[\s\S]*?<\/button>/gi)) {
|
|
329
|
-
seen += textLen(screenHtml.slice(from, m.index)); from = m.index;
|
|
330
|
-
if (seen > firstScreenChars) break;
|
|
331
|
-
if (CONTACT_INTENT.test(m[0].replace(/<[^>]+>/g, ' ')) || CONTACT_INTENT.test(m[0])) { ctaFirstScreen = true; break; }
|
|
332
|
-
}
|
|
333
|
-
}
|
|
383
|
+
const ctaFirstScreen = ctaScan(bodyInner).found;
|
|
334
384
|
/*
|
|
335
385
|
* Видна ли цена. Цена в разметке (Offer, "price") или сумма в валюте в тексте страницы. До 0.33.4
|
|
336
386
|
* строку «Цена видна» черновик писал «цены нет» не глядя, и магазин brainmd.com с ценами на каждом
|
|
337
387
|
* товаре получил «no price on the pages we read» (12.10.2026).
|
|
338
388
|
*/
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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);
|
|
343
397
|
// Trust signals a visitor and a rating system both look for, all of them public on the page.
|
|
344
398
|
const sameAs = [...ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)].flatMap((m) => m[1].match(/https?:\/\/[^"\s,\]]+/g) || []);
|
|
399
|
+
const textContacts = contactsInText(html);
|
|
345
400
|
const trust = {
|
|
346
401
|
postalAddress: /"(?:PostalAddress|streetAddress|addressLocality)"/.test(ld),
|
|
347
402
|
streetAddress: /"streetAddress"\s*:\s*"[^"]+"/.test(ld),
|
|
348
|
-
|
|
349
|
-
|
|
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 || '',
|
|
350
408
|
sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
|
|
351
409
|
sameAsUrls: [...new Set(sameAs)],
|
|
352
410
|
// Карточки на картах и в каталогах, которые сайт показывает виджетом или ссылкой: у cimes.ru в подвале рейтинг
|
|
@@ -356,29 +414,20 @@ export function analysePage(url, html) {
|
|
|
356
414
|
...[...String(html).matchAll(/2gis\.ru\/[^"'\s]*?\/firm\/(\d{6,})/gi)].map((m) => `https://2gis.ru/firm/${m[1]}`),
|
|
357
415
|
])],
|
|
358
416
|
// Только ссылки внутри своего сайта: «yandex.ru/support/.../about» засчитывался cimes.ru как страница о компании (14.10.2026).
|
|
359
|
-
|
|
360
|
-
|
|
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))])],
|
|
361
422
|
author: /"author"\s*:/.test(ld),
|
|
362
423
|
};
|
|
363
|
-
//
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
// двумя абзацами: описание на 19 слов и сразу «Визитка от 28 000 ₽ · … Смета за 1–2 дня» в p.hero-prices, а движок
|
|
368
|
-
// брал только первый <p> и писал «ответа с цифрой нет». Блок вместе укладывается в 20-90 слов: он и есть первый абзац.
|
|
369
|
-
const leadZone = afterH1.split(/<h[2-6]\b|<button\b|<form\b|<a\b[^>]*class=["'][^"']*\bbtn\b/i)[0];
|
|
370
|
-
const leadParas = [...leadZone.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => plain(m[1])).filter(Boolean);
|
|
371
|
-
const wordsOf = (t) => (String(t).match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
|
|
372
|
-
const leadBlock = leadParas.join(' ');
|
|
373
|
-
const onePara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
|
|
374
|
-
const firstPara = (leadParas.length > 1 && wordsOf(onePara) < 20 && wordsOf(leadBlock) <= 90 ? leadBlock : onePara)
|
|
375
|
-
|| ([...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;
|
|
376
428
|
// Фраза, где сайт сам говорит, чем занимается: для строки «Чем занимается» при пустом описании (14.10.2026).
|
|
377
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);
|
|
378
430
|
const h2List = [...bodyHtml.matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi)].map((m) => plain(m[1]).trim()).filter(Boolean).slice(0, 10);
|
|
379
|
-
const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
|
|
380
|
-
// Цифра ответа это число, а не часть слова: «HTML5, CSS3» и «1С» цифрой не считаются (cimes.ru, 15.10.2026).
|
|
381
|
-
const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && /(?<![\p{L}\d])\d+(?:[\s.,]\d+)*(?![\p{L}\d])/u.test(firstPara);
|
|
382
431
|
const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
|
|
383
432
|
const h3Count = (bodyHtml.match(/<h3[\s>]/gi) || []).length;
|
|
384
433
|
const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
|
|
@@ -406,7 +455,8 @@ export function analysePage(url, html) {
|
|
|
406
455
|
const citedParagraphs = paras.filter((x) => x.hrefs.some((h) => {
|
|
407
456
|
try { return new URL(h, url).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
|
|
408
457
|
})).length;
|
|
409
|
-
|
|
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 };
|
|
410
460
|
}
|
|
411
461
|
|
|
412
462
|
/** Карты сайта на живых коммерческих сайтах бывают вложенными десятками: у одного московского
|
|
@@ -576,7 +626,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
576
626
|
// choice the owner is entitled to make, so it is reported and never scored.
|
|
577
627
|
const AI_FETCHERS = ['oai-searchbot', 'chatgpt-user', 'perplexitybot', 'perplexity-user', 'claude-searchbot', 'claude-user', 'bingbot', 'duckassistbot', 'applebot'];
|
|
578
628
|
const blockedFetchers = blockedAgents.filter((a) => AI_FETCHERS.includes(a));
|
|
579
|
-
|
|
629
|
+
// Только известные обучающие роботы ИИ. mj12bot, ahrefsbot, semrushbot, petalbot это SEO-сервисы и поиск Petal, а не ИИ:
|
|
630
|
+
// отчёт писал про них «эти системы не будут учиться на сайте» (прогон на 20 сайтах, 15.10.2026).
|
|
631
|
+
const blockedTrainers = aiTrainers(blockedAgents.filter((a) => !AI_FETCHERS.includes(a)));
|
|
580
632
|
/*
|
|
581
633
|
* Доступ по движкам, а не одной строкой. Раньше это была одна проверка на девять роботов, и
|
|
582
634
|
* человек видел «заблокировано 2» без понимания, кто именно его не увидит. «Perplexity вас не
|
|
@@ -632,9 +684,15 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
632
684
|
// считался мёртвым, а «brainmd.com.» чужим хостом (12.10.2026).
|
|
633
685
|
const llmsLinks = llmsIsText ? linksInText(llms.text) : [];
|
|
634
686
|
const llmsForeign = llmsLinks.filter((u) => { try { return !ownHosts.has(new URL(u).host); } catch { return false; } });
|
|
635
|
-
|
|
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,
|
|
636
694
|
!llms.ok ? 'not present' : !llmsIsText ? 'answers with an HTML page, not a text index' : `${llmsLinks.length} link(s), ${llmsForeign.length} to other hosts`,
|
|
637
|
-
!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` : ''));
|
|
638
696
|
|
|
639
697
|
// Здоровье llms.txt: мы проверяем не только то, что файл есть, но и то, что он ведёт в живые
|
|
640
698
|
// страницы. Индекс для ИИ разъезжается тихо: страницу переименовали, закрыли от индексации или
|
|
@@ -760,7 +818,8 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
760
818
|
// «Сейчас: 0 знаков» в задании для исполнителя читается как обрывок: человеку непонятно,
|
|
761
819
|
// что описания у страницы просто не существует. Где ноль это отсутствие, пишем словами.
|
|
762
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' : ''));
|
|
763
|
-
|
|
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' : ''));
|
|
764
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' : ''));
|
|
765
824
|
/*
|
|
766
825
|
* Раньше canonical смотрелся только на главной, хотя десять страниц уже прочитаны. Так и
|
|
@@ -772,16 +831,16 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
772
831
|
*/
|
|
773
832
|
checks.push(row('canonical', 'technical', 'Canonical tag', good.every((x) => x.canonical) ? 'ok' : good.some((x) => x.canonical) ? 'warn' : 'bad',
|
|
774
833
|
`${good.filter((x) => x.canonical).length} of ${good.length} sampled page(s) carry a canonical tag`,
|
|
775
|
-
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(', ')}`));
|
|
776
835
|
checks.push(row('title-all', 'onpage', 'Title on the sampled pages', good.every((x) => x.titleLength) ? 'ok' : 'bad',
|
|
777
836
|
`${good.filter((x) => x.titleLength).length} of ${good.length} sampled page(s) carry a title`,
|
|
778
|
-
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(', ')}`));
|
|
779
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',
|
|
780
839
|
`${good.filter((x) => x.descriptionLength).length} of ${good.length} sampled page(s) carry a meta description`,
|
|
781
|
-
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(', ')}`));
|
|
782
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',
|
|
783
842
|
`${good.filter((x) => x.schemaTypes.length).length} of ${good.length} sampled page(s) carry JSON-LD`,
|
|
784
|
-
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(', ')}`));
|
|
785
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' : ''));
|
|
786
845
|
/*
|
|
787
846
|
* Раньше это была одна проверка на og:title, og:image и Twitter Card. Это три разные правки в
|
|
@@ -822,39 +881,55 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
822
881
|
checks.push(row('hreflang', 'technical', 'hreflang', hp.hreflang ? 'ok' : 'na', hp.hreflang ? `${hp.hreflang} alternate(s)` : 'none (single language)'));
|
|
823
882
|
}
|
|
824
883
|
const imgs = good.reduce((a, p) => a + p.images, 0); const noAlt = good.reduce((a, p) => a + p.imagesNoAlt, 0);
|
|
825
|
-
|
|
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}`) : ''}`));
|
|
826
889
|
const multiH1 = good.filter((p) => p.h1Count > 1).length;
|
|
827
890
|
// Страницы вовсе без H1 тоже считаются (14.10.2026): на medeq-mc.ru у 11 из 20 страниц H1 не было, а отчёт писал «по одному H1 на страницу».
|
|
828
891
|
const noH1 = good.filter((p) => !p.h1Count).length;
|
|
829
892
|
if (good.length > 1) {
|
|
830
893
|
const h1Status = noH1 > good.length / 4 || multiH1 > good.length / 2 ? 'bad' : noH1 || multiH1 ? 'warn' : 'ok';
|
|
831
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';
|
|
832
|
-
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));
|
|
833
896
|
}
|
|
834
897
|
const titles = new Map(); for (const p of good) titles.set(p.title, (titles.get(p.title) || 0) + 1);
|
|
835
898
|
const dupTitles = [...titles].filter(([, n]) => n > 1);
|
|
836
|
-
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'));
|
|
837
900
|
// Список статей, отзывы с виджетом, рубрики короткие по устройству: их не считаем тонкими (14.10.2026, neronov.pro).
|
|
838
901
|
const LISTY = /\/(articles|blog|news|novosti|stati|otzyvy|reviews|testimonials|category|tag|page\/\d+)\/?$/i;
|
|
839
902
|
const thin = good.filter((p) => p.words < 300 && !LISTY.test(new URL(p.url).pathname));
|
|
840
|
-
|
|
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));
|
|
905
|
+
// Подпись нейтральная: при «ok» строка уходит в «сильные стороны», и «Thin pages in sample: median 1370 words» там
|
|
906
|
+
// читалась как беда (oper-stack.com, прогон на своих сайтах 15.10.2026).
|
|
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`));
|
|
841
908
|
// «shop» это витрина, а не служебная страница: cimes.ru/solutions/shop/ продающий каталог (14.10.2026). Только целый сегмент пути.
|
|
842
|
-
|
|
909
|
+
// «compare» не берём: у oper-stack.com /compare/ это раздел сравнений на тысячу слов, а не сравнение товаров магазина
|
|
910
|
+
// (прогон на своих сайтах 15.10.2026). По адресу их не различить, а служебная страница сравнения в карте сайта редкость.
|
|
911
|
+
const utility = sitemapPages.filter((u) => /\/(cart|checkout|wishlist|my-account|tag|author|feed)(\/|$)/i.test(new URL(u).pathname));
|
|
843
912
|
if (utility.length) {
|
|
844
913
|
// Пояснение называет только то, что нашлось: «корзина» у клиники без корзины выглядела копипастом (14.10.2026).
|
|
845
|
-
const KIND = [[/\/author\//i, 'author pages'], [/\/tag\//i, 'tag pages'], [/\/(cart|checkout)\b/i, 'cart and checkout pages'], [/\/wishlist
|
|
914
|
+
const KIND = [[/\/author\//i, 'author pages'], [/\/tag\//i, 'tag pages'], [/\/(cart|checkout)\b/i, 'cart and checkout pages'], [/\/wishlist/i, 'wishlist pages'], [/\/my-account/i, 'account pages'], [/\/feed\b/i, 'feeds']];
|
|
846
915
|
const kinds = [...new Set(utility.flatMap((u) => KIND.filter(([re]) => re.test(new URL(u).pathname)).map(([, k]) => k)))];
|
|
847
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`));
|
|
848
917
|
}
|
|
849
918
|
const redirected = sample.filter((p) => p.redirect).length; const broken = sample.filter((p) => p.status && p.status >= 400).length;
|
|
850
|
-
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` : ''}`));
|
|
851
920
|
if (good.length) {
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
const
|
|
855
|
-
|
|
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'));
|
|
856
931
|
const withTables = good.filter((p) => p.tables > 0).length;
|
|
857
|
-
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'));
|
|
858
933
|
/*
|
|
859
934
|
* Источник у цифры. Правило не наше: в работе Aggarwal и др. (KDD 2024, 10 000 запросов,
|
|
860
935
|
* девять способов правки) «назвать источник» дало самый большой прирост видимости из всех
|
|
@@ -874,11 +949,12 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
874
949
|
* проверке различить это нельзя.
|
|
875
950
|
*/
|
|
876
951
|
const SELF_SOURCED = /^(Product|Offer|Service|AggregateOffer|SoftwareApplication|Course|Event|JobPosting|Recipe|RealEstateListing)$/;
|
|
877
|
-
|
|
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));
|
|
878
954
|
if (judged.length) {
|
|
879
955
|
const sourced = judged.filter((p) => p.sourcePhrases > 0 || p.citedParagraphs > 0).length;
|
|
880
956
|
checks.push(row('sources', 'content', 'Sources named in the text', sourced === judged.length ? 'ok' : sourced ? 'warn' : 'bad',
|
|
881
|
-
`${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)` : ''}`,
|
|
882
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'));
|
|
883
959
|
} else {
|
|
884
960
|
checks.push(row('sources', 'content', 'Sources named in the text', 'na',
|
|
@@ -892,16 +968,20 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
892
968
|
// Видимая дата засчитывается, только если совпадает с датой в разметке: дата лицензии или чужой новости в боковом блоке это не дата страницы.
|
|
893
969
|
const seen = good.filter((p) => p.dates.visibleMatch).length;
|
|
894
970
|
// Даты нужны статьям, новостям и кейсам. Нет таких страниц в выборке: оценивать нечего, а не «Частично» (cimes.ru, 14.10.2026).
|
|
895
|
-
|
|
971
|
+
// Статья Shopify это /blogs/<блог>/<статья>; /blogs/wellness сам по себе список статей.
|
|
972
|
+
const ARTICLE = /\/(blog|news|novosti|stati|statya|article|articles|post|posts|journal|zhurnal|media|press|kejsy?|cases?|portfolio|insights|guides?)\/[^/]+|\/blogs\/[^/]+\/[^/]+/i;
|
|
896
973
|
const NOT_ARTICLE = /\/(contact|contacts|kontakty?|about|o-nas|o-kompanii|uslugi|services?|price|ceny|prajs|policy|politika|soglasie|cart|checkout)(\/|$)/i;
|
|
897
|
-
|
|
974
|
+
// Товар, каталог и страница магазина не статья, даже если движок магазина ставит им datePublished: у brainmd.com Shopify
|
|
975
|
+
// ставит дату каждому товару, и отчёт писал «дата в разметке на 18 из 20, видна на 0», хотя статьи блога даты показывают (14.10.2026).
|
|
976
|
+
const SHOP = /\/(products?|collections?|catalog|katalog|shop|pages|product-category|tovary?)(\/|$)/i;
|
|
977
|
+
const articleLike = good.filter((p) => { const path = new URL(p.url).pathname; return ARTICLE.test(path) || (p.schemaTypes || []).some((t) => /^(Article|BlogPosting|NewsArticle|Report|MedicalWebPage)$/.test(t)) || (p.dates?.published && !NOT_ARTICLE.test(path) && !SHOP.test(path) && !(p.schemaTypes || []).includes('Product') && path !== '/'); });
|
|
898
978
|
// Виды считаем отдельно и называем только те, что есть: «статьи блога и новости, 271» было 231 статьёй и 40 работами
|
|
899
979
|
// портфолио, а новостей у cimes.ru нет вовсе (15.10.2026).
|
|
900
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;
|
|
901
|
-
const notSampled = { blog: kindCount(/\/(
|
|
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) };
|
|
902
982
|
const notSampledLine = Object.entries(notSampled).filter(([, n]) => n).map(([k, n]) => `${k} ${n}`).join(', ');
|
|
903
|
-
if (good.length > 2 && !articleLike.length) checks.push(row('dates', 'content', 'Publication dates
|
|
904
|
-
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'));
|
|
905
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')); }
|
|
906
986
|
|
|
907
987
|
// ---- Off-page and trust ----------------------------------------------------------------
|
|
@@ -957,12 +1037,12 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
957
1037
|
const sameAsAll = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].join(' ');
|
|
958
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);
|
|
959
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)))];
|
|
960
|
-
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'));
|
|
961
1041
|
if (!profiles.length && messengerNames.length) checks[checks.length - 1].messengersOnly = messengerNames;
|
|
962
1042
|
if (!profiles.length && listingsMissing.length) { checks[checks.length - 1].listings = listingsMissing; checks[checks.length - 1].status = 'warn'; }
|
|
963
|
-
const about =
|
|
1043
|
+
const about = (pagesForTrust.find((p) => p.trust?.aboutPage) || {}).trust?.aboutPage || '';
|
|
964
1044
|
// Страницы о компании нет: это «Проблема», а не «Частично» (cimes.ru, 14.10.2026).
|
|
965
|
-
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'));
|
|
966
1046
|
const policies = [...new Set(pagesForTrust.flatMap((p) => p.trust?.policyPages || []))];
|
|
967
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'));
|
|
968
1048
|
|
|
@@ -970,10 +1050,10 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
970
1050
|
// Measured from the pages, not from analytics: whether a visitor who wants to talk can, and how
|
|
971
1051
|
// far down the page the way to do it sits. Behaviour data, when the client grants it, adds to this.
|
|
972
1052
|
const withForm = pagesForTrust.filter((p) => p.forms > 0);
|
|
973
|
-
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);
|
|
974
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`));
|
|
975
1055
|
const withCta = pagesForTrust.filter((p) => p.ctaFirstScreen);
|
|
976
|
-
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
|
|
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'));
|
|
977
1057
|
const depths = withForm.map((p) => p.formDepth).filter((d) => d !== null && d !== undefined);
|
|
978
1058
|
if (depths.length) {
|
|
979
1059
|
const md = median(depths);
|
|
@@ -982,8 +1062,12 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
982
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' : ''));
|
|
983
1063
|
} else if (pagesForTrust.some((p) => p.formPopup) && withCta.length) {
|
|
984
1064
|
// Заявка во всплывающем окне по кнопке: глубины нет, есть кнопка на первом экране.
|
|
985
|
-
|
|
986
|
-
|
|
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`));
|
|
987
1071
|
} else {
|
|
988
1072
|
// Формы нет, но заявки идут через мессенджеры: это не провал, а другой способ (14.10.2026).
|
|
989
1073
|
const viaMsg = pagesForTrust.filter((p) => p.messengerLinks).length;
|
|
@@ -993,7 +1077,8 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
993
1077
|
const fields = withForm.filter((p) => p.formDepth !== null && p.formDepth !== undefined).map((p) => p.formFields).filter((n) => n > 0);
|
|
994
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' : '')); }
|
|
995
1079
|
const msg = pagesForTrust.filter((p) => p.messengerLinks > 0);
|
|
996
|
-
|
|
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'));
|
|
997
1082
|
|
|
998
1083
|
// Оценки считаются до перевода: язык на цифры не влияет.
|
|
999
1084
|
// Сколько текста не видно без выполнения скриптов. Меряется настоящим браузером и только по
|
|
@@ -1075,7 +1160,8 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
1075
1160
|
rows: ['Система управления сайтом (CMS)', 'Страниц в карте сайта', 'Язык', 'Чем занимается'],
|
|
1076
1161
|
partial: (n, read, total) => `не меньше ${n}: прочитано ${read} из ${total} файлов карты, остальные не успели`,
|
|
1077
1162
|
site: 'Сам сайт', free: 'Бесплатно, без аккаунта',
|
|
1078
|
-
|
|
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 ? 'найдена' : 'найдены'} по ссылкам с главной, потому что в карте сайта нашлось меньше адресов, чем нужно для выборки` : ''}`,
|
|
1079
1165
|
paid: 'Платный сервис, по желанию', paidDetail: 'только для справки, ни в одну оценку не входит',
|
|
1080
1166
|
limitations: ['Google Search Console и Яндекс Вебмастер: показов, кликов и запросов нет', 'Аналитика: данных о посещениях и поведении нет', 'Сервисы ключевых слов и ссылок: в проверке по публичным сигналам не используются'],
|
|
1081
1167
|
fix: 'Исправить',
|
|
@@ -1086,7 +1172,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
1086
1172
|
rows: ['CMS / stack', 'Pages in sitemap', 'Language', 'What is sold'],
|
|
1087
1173
|
partial: (n, read, total) => `at least ${n}: ${read} of ${total} sitemap files were read, the rest were not reached`,
|
|
1088
1174
|
site: 'The site itself', free: 'Free, no account needed',
|
|
1089
|
-
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` : ''}`,
|
|
1090
1176
|
paid: 'Paid tool, optional', paidDetail: 'reported for context only, not counted in any score',
|
|
1091
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'],
|
|
1092
1178
|
fix: 'Fix',
|
|
@@ -1097,7 +1183,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
1097
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;
|
|
1098
1184
|
|
|
1099
1185
|
return {
|
|
1100
|
-
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)
|
|
1101
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' },
|
|
1102
1188
|
overall,
|
|
1103
1189
|
reportScore,
|
|
@@ -1128,7 +1214,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
1128
1214
|
reading,
|
|
1129
1215
|
/* Сырьё для готовых файлов: профили, которые сайт сам про себя заявляет, и есть ли контакты. */
|
|
1130
1216
|
entity: { profiles: profileUrls, hosts: profiles, telephone: Boolean(hp?.trust?.telephone), email: Boolean(hp?.trust?.email) },
|
|
1131
|
-
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),
|
|
1132
1218
|
notes,
|
|
1133
1219
|
};
|
|
1134
1220
|
}
|