@operstack/audit 0.33.3 → 0.34.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/package.json +8 -3
- package/src/collect.mjs +222 -46
- package/src/consistency.mjs +90 -0
- package/src/etalon.mjs +37 -0
- package/src/i18n.mjs +26 -20
- package/src/index.mjs +6 -0
- package/src/narrative.mjs +57 -21
- package/src/niche.mjs +91 -0
- package/src/prompts.mjs +146 -5
- package/src/render.mjs +74 -16
- package/src/rendered.mjs +12 -2
- package/src/review.mjs +271 -0
- package/src/tasks-pdf.mjs +67 -0
- package/src/test-agent-surface.mjs +1 -1
- package/src/test-etalon.mjs +42 -0
- package/src/test-narrative.mjs +11 -2
- package/src/test-prompts.mjs +2 -2
- package/src/test-rendered.mjs +11 -2
- package/src/test-shopify-store.mjs +159 -0
- package/src/verify.mjs +347 -0
- package/src/visibility.mjs +40 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,7 +17,12 @@
|
|
|
17
17
|
"./narrative": "./src/narrative.mjs",
|
|
18
18
|
"./prompts": "./src/prompts.mjs",
|
|
19
19
|
"./rendered": "./src/rendered.mjs",
|
|
20
|
-
"./demand": "./src/demand.mjs"
|
|
20
|
+
"./demand": "./src/demand.mjs",
|
|
21
|
+
"./tasks-pdf": "./src/tasks-pdf.mjs",
|
|
22
|
+
"./verify": "./src/verify.mjs",
|
|
23
|
+
"./review": "./src/review.mjs",
|
|
24
|
+
"./consistency": "./src/consistency.mjs",
|
|
25
|
+
"./niche": "./src/niche.mjs"
|
|
21
26
|
},
|
|
22
27
|
"files": [
|
|
23
28
|
"bin",
|
|
@@ -29,7 +34,7 @@
|
|
|
29
34
|
"LICENSE"
|
|
30
35
|
],
|
|
31
36
|
"scripts": {
|
|
32
|
-
"test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs && node src/test-report-wording.mjs",
|
|
37
|
+
"test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs && node src/test-report-wording.mjs && node src/test-shopify-store.mjs && node src/test-etalon.mjs",
|
|
33
38
|
"sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
|
|
34
39
|
"verify:live": "node src/verify-live.mjs"
|
|
35
40
|
},
|
package/src/collect.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import { renderedDom, compareReadings, jsBlindnessCheck } from './rendered.mjs';
|
|
10
10
|
import { parse } from 'node-html-parser';
|
|
11
11
|
import { localiseChecks } from './i18n.mjs';
|
|
12
|
-
import { checkVisibility, VISIBILITY_DEFAULTS, internalLinks, robotsAllows } from './visibility.mjs';
|
|
12
|
+
import { checkVisibility, VISIBILITY_DEFAULTS, internalLinks, robotsAllows, linksInText } from './visibility.mjs';
|
|
13
13
|
|
|
14
14
|
/** Версия читается из своего package.json. Зашитая строка врала в каждом отчёте с первого релиза:
|
|
15
15
|
* в подвале стояло 0.1.0, когда пакет был уже 0.12.0, и покупатель не мог понять, чем его мерили. */
|
|
@@ -154,20 +154,66 @@ const row = (id, group, label, status, value, comment = '') => ({ id, group, lab
|
|
|
154
154
|
*/
|
|
155
155
|
const SOURCE_RE = /(?<!\p{L})(according to|source:|sources:|data from|reported by|published by|registry|statistics office|central bank|по данным|источник:|источники:|согласно|по информации|по сведениям|росстат|центробанк|банк россии|росреестр|минфин|минэкономразвития)(?!\p{L})/giu;
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Движок сайта по следам в коде, когда мета-тега generator нет. Shopify его не ставит, и до 0.33.4
|
|
159
|
+
* отчёт о магазине на Shopify писал «CMS: не измерено», хотя в коде стоят Shopify.shop и
|
|
160
|
+
* cdn.shopify.com (brainmd.com, 12.10.2026). Только надёжные следы: лучше промолчать, чем угадать.
|
|
161
|
+
*/
|
|
162
|
+
const STACK_SIGNS = [
|
|
163
|
+
['Shopify', /\bShopify\.shop\b|cdn\.shopify\.com|\/cdn\/shop\/|myshopify\.com/i],
|
|
164
|
+
['WordPress', /\/wp-content\/|\/wp-includes\//i],
|
|
165
|
+
['Tilda', /static\.tildacdn\.|tilda-blocks/i],
|
|
166
|
+
['Wix', /static\.wixstatic\.com|wix-warmup-data/i],
|
|
167
|
+
['Squarespace', /static1\.squarespace\.com|Static\.SQUARESPACE_CONTEXT/i],
|
|
168
|
+
['Webflow', /\bdata-wf-page=|assets\.website-files\.com|cdn\.prod\.website-files\.com/i],
|
|
169
|
+
['1C-Bitrix', /\/bitrix\/(js|templates|cache)\//i],
|
|
170
|
+
['BigCommerce', /cdn\d*\.bigcommerce\.com/i],
|
|
171
|
+
['Magento', /Magento_|mage\/cookies/i],
|
|
172
|
+
['Joomla', /\/media\/jui\/|\/components\/com_/i],
|
|
173
|
+
['Drupal', /\/sites\/default\/files\/|drupal-settings-json/i],
|
|
174
|
+
['Ghost', /ghost-(portal|search)|\/ghost\/api\//i],
|
|
175
|
+
['Next.js', /\/_next\/static\//],
|
|
176
|
+
['Nuxt', /\/_nuxt\//],
|
|
177
|
+
['Gatsby', /___gatsby/],
|
|
178
|
+
];
|
|
179
|
+
export function detectStack(html) {
|
|
180
|
+
const src = String(html || '');
|
|
181
|
+
const hit = STACK_SIGNS.find(([, re]) => re.test(src));
|
|
182
|
+
return hit ? hit[0] : '';
|
|
183
|
+
}
|
|
184
|
+
|
|
157
185
|
function analysePage(url, html) {
|
|
158
186
|
const root = parse(html, { blockTextElements: { script: true, style: true, noscript: true } });
|
|
159
187
|
const attr = (sel, a) => root.querySelector(sel)?.getAttribute(a) ?? '';
|
|
160
188
|
const title = root.querySelector('title')?.text.trim() ?? '';
|
|
161
189
|
const description = attr('meta[name="description"]', 'content').trim();
|
|
162
|
-
|
|
190
|
+
// H1 из шаблонов Vue и подобных (v-if, :title, {{ }} или внутри <template>) человек не видит: не считаем (14.10.2026, brainmd.com).
|
|
191
|
+
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; };
|
|
192
|
+
const framework = (el) => Object.keys(el.attributes || {}).some((k) => /^(v-|:|@|x-)/.test(k)) || /\{\{|\}\}/.test(el.text);
|
|
193
|
+
const h1s = root.querySelectorAll('h1').filter((h) => !inTemplate(h) && !framework(h)).map((h) => h.text.trim()).filter(Boolean);
|
|
163
194
|
const imgs = root.querySelectorAll('img');
|
|
164
|
-
|
|
195
|
+
// Значки оформления (svg с пустым alt) и счётчики-пиксели не картинки содержания: пустой alt у них правильный.
|
|
196
|
+
// Фото, выведенные блоком с role="img" и aria-label (Tilda), считаем подписанными (14.10.2026).
|
|
197
|
+
const decorative = (i) => (/\.svg(\?|$)/i.test(i.getAttribute('src') || i.getAttribute('data-original') || '') && !(i.getAttribute('alt') || '').trim()) || /mc\.yandex|facebook\.com\/tr|pixel/i.test(i.getAttribute('src') || '');
|
|
198
|
+
const roleImgs = root.querySelectorAll('[role="img"]').filter((e) => String(e.rawTagName || '').toLowerCase() !== 'img' && String(e.rawTagName || '').toLowerCase() !== 'svg');
|
|
199
|
+
const contentImgs = imgs.filter((i) => !decorative(i));
|
|
200
|
+
const imgsNoAlt = contentImgs.filter((i) => !(i.getAttribute('alt') || '').trim()).length + roleImgs.filter((e) => !(e.getAttribute('aria-label') || '').trim()).length;
|
|
201
|
+
// Атрибута нет вовсе или он есть, но пустой: это разные задачи (14.10.2026, medeq-mc.ru: alt стоял у всех 1228 картинок, пустой у 1163).
|
|
202
|
+
const imgsAltMissing = imgs.filter((i) => i.getAttribute('alt') === null || i.getAttribute('alt') === undefined).length;
|
|
165
203
|
const canonical = attr('link[rel="canonical"]', 'href');
|
|
166
204
|
const robots = attr('meta[name="robots"]', 'content');
|
|
167
205
|
const viewport = attr('meta[name="viewport"]', 'content');
|
|
168
206
|
const og = { title: attr('meta[property="og:title"]', 'content'), description: attr('meta[property="og:description"]', 'content'), image: attr('meta[property="og:image"]', 'content') };
|
|
169
207
|
const twitter = attr('meta[name="twitter:card"]', 'content');
|
|
170
208
|
const generator = attr('meta[name="generator"]', 'content');
|
|
209
|
+
/*
|
|
210
|
+
* Язык страницы. До 0.33.4 его не читали вовсе: в meta.language шёл og:locale, которого
|
|
211
|
+
* analysePage не собирал, и отчёт писал «язык не указан» даже там, где lang стоит. На brainmd.com
|
|
212
|
+
* тег <html> ещё и разбит на несколько строк (12.10.2026), поэтому кроме парсера есть
|
|
213
|
+
* запасное правило, которому перевод строки внутри тега не мешает.
|
|
214
|
+
*/
|
|
215
|
+
const htmlLang = (root.querySelector('html')?.getAttribute('lang') || (String(html).match(/<html\b[^>]*?\slang\s*=\s*["']?([a-zA-Z]{2,3}(?:[-_][a-zA-Z0-9]{2,8})*)/i) || [])[1] || attr('meta[property="og:locale"]', 'content') || '').trim();
|
|
216
|
+
const stack = generator || detectStack(html);
|
|
171
217
|
const hreflang = root.querySelectorAll('link[rel="alternate"][hreflang]').length;
|
|
172
218
|
const scripts = root.querySelectorAll('script[src]').length;
|
|
173
219
|
const stylesheets = root.querySelectorAll('link[rel="stylesheet"]').length;
|
|
@@ -196,13 +242,23 @@ function analysePage(url, html) {
|
|
|
196
242
|
// шаблоне выглядит на три тысячи слов и проверка на пустые страницы не значит ничего.
|
|
197
243
|
const words = countWords(plain(bodyHtml)) || countWords(text);
|
|
198
244
|
const links = root.querySelectorAll('a[href]').map((a) => a.getAttribute('href') || '');
|
|
199
|
-
|
|
245
|
+
// Shopify и другие пишут слеши в JSON-LD как \/: без раскодирования "sameAs" с семью профилями
|
|
246
|
+
// brainmd.com читался как «профилей нет», и это шло в приоритеты отчёта (12.10.2026).
|
|
247
|
+
const ld = root.querySelectorAll('script[type="application/ld+json"]').map((x) => x.text).join('\n').replace(/\\\//g, '/');
|
|
200
248
|
const dates = { published: attr('meta[property="article:published_time"]', 'content') || (ld.match(/"datePublished"\s*:\s*"([^"]+)"/) || [])[1] || '', modified: attr('meta[property="article:modified_time"]', 'content') || (ld.match(/"dateModified"\s*:\s*"([^"]+)"/) || [])[1] || '' };
|
|
249
|
+
{ const top = String(text || '').slice(0, Math.max(600, Math.round(String(text || '').length * 0.3)));
|
|
250
|
+
const isos = [dates.published, dates.modified].filter(Boolean).map((x) => String(x).slice(0, 10));
|
|
251
|
+
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
252
|
+
const MONTHS_RU = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
|
|
253
|
+
const tx = String(text || '');
|
|
254
|
+
// Совпадение с датой публикации или с датой изменения: «Обновлено: 7 октября 2026» тоже честная видимая дата.
|
|
255
|
+
dates.visibleMatch = isos.some((iso) => { const [Y, M, D] = iso.split('-'); if (!Y || !D) return false; const mi = Number(M) - 1; const d = Number(D); return [`${D}.${M}.${Y}`, `${d}.${M}.${Y}`, `${MONTHS_EN[mi]} ${d}, ${Y}`, `${d} ${MONTHS_EN[mi]} ${Y}`, `${d} ${MONTHS_RU[mi]} ${Y}`, `${Y}-${M}-${D}`].some((x) => tx.includes(x)); });
|
|
256
|
+
dates.visible = /<time\b/i.test(html.slice(0, Math.round(html.length * 0.5))) || /(?<!\d)\d{1,2}[./]\d{1,2}[./](?:19|20)\d{2}(?!\d)|\d{1,2}\s+(?:января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря|January|February|March|April|May|June|July|August|September|October|November|December)\s+(?:19|20)\d{2}|(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s+(?:19|20)\d{2}/i.test(top); }
|
|
201
257
|
const shortcode = /\[[a-z_-]+ [^\]]*\]/i.test(description) || /^[A-Za-z0-9+/=]{40,}$/.test(description);
|
|
202
258
|
// Conversion path, read from the page itself. No analytics is needed to see whether a visitor
|
|
203
259
|
// who wants to talk to this business can find a way to do it, and how far down it is.
|
|
204
260
|
const bodyInner = body?.innerHTML || html;
|
|
205
|
-
const CONTACT_INTENT = /contact|enquir|inquir|get in touch|request a|book a|call us|consult|get a quote|free quote|order (the|an|a|your)|buy |get started|start (here|free|now)|try it|check (your|a) site|audit|pricing
|
|
261
|
+
const CONTACT_INTENT = /contact|enquir|inquir|get in touch|request a|book a|call us|consult|get a quote|free quote|order (the|an|a|your)|buy |buy now|add to (cart|bag|basket)|shop (now|all|the)|take the quiz|start the quiz|subscribe (and|&|&) save|get started|start (here|free|now)|try it|check (your|a) site|audit|pricing|связ|заяв|консульт|оставить|закаж|закажите|купить|в корзину|добавить в корзину|узнать цен|проверить сайт|начать/i;
|
|
206
262
|
const hrefsLower = links.map((h) => h.toLowerCase());
|
|
207
263
|
const telLinks = hrefsLower.filter((h) => h.startsWith('tel:')).length;
|
|
208
264
|
const mailLinks = hrefsLower.filter((h) => h.startsWith('mailto:')).length;
|
|
@@ -210,6 +266,11 @@ function analysePage(url, html) {
|
|
|
210
266
|
const contactPages = hrefsLower.filter((h) => /\/(contact|contacts|contact-us|kontakt|svyaz|contacto)\/?($|[?#])/.test(h)).length;
|
|
211
267
|
const formEls = root.querySelectorAll('form');
|
|
212
268
|
const realForms = formEls.filter((f) => !/search/i.test(`${f.getAttribute('class') || ''} ${f.getAttribute('id') || ''} ${f.getAttribute('role') || ''} ${f.getAttribute('action') || ''}`));
|
|
269
|
+
// Виджет записи без тега <form> (поля телефона и имени в обычном блоке) это тоже форма (14.10.2026, neronov.pro).
|
|
270
|
+
if (!realForms.length) {
|
|
271
|
+
const tel = root.querySelectorAll('input').find((i) => /tel/i.test(`${i.getAttribute('type') || ''} ${i.getAttribute('inputmode') || ''}`) || /phone|tel|телефон|номер|\+7|\+1/i.test(`${i.getAttribute('name') || ''} ${i.getAttribute('placeholder') || ''} ${Object.keys(i.attributes || {}).join(' ')}`));
|
|
272
|
+
if (tel) { let box = tel.parentNode; for (let k = 0; k < 4 && box && box.parentNode && box.querySelectorAll('input').length < 2; k++) box = box.parentNode; if (box) realForms.push(box); }
|
|
273
|
+
}
|
|
213
274
|
const firstForm = realForms[0] || null;
|
|
214
275
|
// A honeypot is invisible to a person, so counting it as a field the visitor must fill is wrong.
|
|
215
276
|
const visibleToVisitor = (el) => {
|
|
@@ -226,13 +287,42 @@ function analysePage(url, html) {
|
|
|
226
287
|
const formFields = firstForm ? firstForm.querySelectorAll('input, textarea, select').filter((i) => !/^(hidden|submit|button|image)$/i.test(i.getAttribute('type') || '')).filter(visibleToVisitor).length : 0;
|
|
227
288
|
const formAt = firstForm ? bodyInner.indexOf(firstForm.outerHTML.slice(0, 120)) : -1;
|
|
228
289
|
const formDepth = firstForm && formAt >= 0 && bodyInner.length ? Math.round((formAt / bodyInner.length) * 100) : null;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
290
|
+
/*
|
|
291
|
+
* Первый экран меряем долей видимого текста, а не долей разметки. До 0.33.4 брались первые 15%
|
|
292
|
+
* HTML, а у магазина на Shopify их съедают меню, галерея и иконки: кнопка «Add To Cart» на
|
|
293
|
+
* странице товара стоит на 4% текста и на 30% разметки, и все 21 страница brainmd.com вышли
|
|
294
|
+
* «без призыва на первом экране» (12.10.2026). Скрипты, стили, svg, меню и подвал не видны как
|
|
295
|
+
* текст первого экрана и в счёт не идут.
|
|
296
|
+
*/
|
|
297
|
+
const screenHtml = bodyInner.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
298
|
+
.replace(/<(script|style|svg|template|noscript)\b[\s\S]*?<\/\1\s*>/gi, ' ')
|
|
299
|
+
.replace(/<(nav|footer|aside)\b[\s\S]*?<\/\1\s*>/gi, ' ');
|
|
300
|
+
const textLen = (x) => x.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().length;
|
|
301
|
+
const screenText = textLen(screenHtml);
|
|
302
|
+
const firstScreenChars = Math.max(300, Math.round(screenText * 0.15));
|
|
303
|
+
let ctaFirstScreen = false;
|
|
304
|
+
{
|
|
305
|
+
let seen = 0; let from = 0;
|
|
306
|
+
for (const m of screenHtml.matchAll(/<a[^>]*>[\s\S]*?<\/a>|<button[^>]*>[\s\S]*?<\/button>/gi)) {
|
|
307
|
+
seen += textLen(screenHtml.slice(from, m.index)); from = m.index;
|
|
308
|
+
if (seen > firstScreenChars) break;
|
|
309
|
+
if (CONTACT_INTENT.test(m[0].replace(/<[^>]+>/g, ' ')) || CONTACT_INTENT.test(m[0])) { ctaFirstScreen = true; break; }
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/*
|
|
313
|
+
* Видна ли цена. Цена в разметке (Offer, "price") или сумма в валюте в тексте страницы. До 0.33.4
|
|
314
|
+
* строку «Цена видна» черновик писал «цены нет» не глядя, и магазин brainmd.com с ценами на каждом
|
|
315
|
+
* товаре получил «no price on the pages we read» (12.10.2026).
|
|
316
|
+
*/
|
|
317
|
+
const priceShown = /"@type"\s*:\s*"(?:Offer|AggregateOffer)"|"price"\s*:\s*"?\d/.test(ld)
|
|
318
|
+
// Нулевая сумма это пустая корзина в шапке («Spend $0.00 more»), а не цена: на brainmd.com она
|
|
319
|
+
// стояла на каждой странице, и цена «нашлась» даже на странице контактов.
|
|
320
|
+
|| /(?:[$€£₽]\s?(?:[1-9]|0[.,]\d*[1-9])|(?<![\d.,])[1-9][\d\s.,]*\s?(?:₽|руб(?:\.|лей|ля|ль)?|USD|EUR|GBP)(?![\p{L}]))/u.test(text);
|
|
232
321
|
// Trust signals a visitor and a rating system both look for, all of them public on the page.
|
|
233
322
|
const sameAs = [...ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)].flatMap((m) => m[1].match(/https?:\/\/[^"\s,\]]+/g) || []);
|
|
234
323
|
const trust = {
|
|
235
324
|
postalAddress: /"(?:PostalAddress|streetAddress|addressLocality)"/.test(ld),
|
|
325
|
+
streetAddress: /"streetAddress"\s*:\s*"[^"]+"/.test(ld),
|
|
236
326
|
telephone: /"telephone"\s*:/.test(ld) || telLinks > 0,
|
|
237
327
|
email: /"email"\s*:/.test(ld) || mailLinks > 0,
|
|
238
328
|
sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
|
|
@@ -243,10 +333,16 @@ function analysePage(url, html) {
|
|
|
243
333
|
};
|
|
244
334
|
// Answer-first family, the same measures the free AI visibility check uses, so the two agree.
|
|
245
335
|
const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
|
|
246
|
-
|
|
336
|
+
// Без H1 первый абзац берём из тела страницы: иначе у главной без H1 «текста нет» (14.10.2026, medeq-mc.ru).
|
|
337
|
+
const firstPara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1])
|
|
338
|
+
|| ([...bodyHtml.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => plain(m[1])).find((t) => (t.match(/[\p{L}]+/gu) || []).length >= 12) || '');
|
|
339
|
+
// Фраза, где сайт сам говорит, чем занимается: для строки «Чем занимается» при пустом описании (14.10.2026).
|
|
340
|
+
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);
|
|
341
|
+
const h2List = [...bodyHtml.matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi)].map((m) => plain(m[1]).trim()).filter(Boolean).slice(0, 10);
|
|
247
342
|
const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
|
|
248
343
|
const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && /\d/.test(firstPara);
|
|
249
344
|
const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
|
|
345
|
+
const h3Count = (bodyHtml.match(/<h3[\s>]/gi) || []).length;
|
|
250
346
|
const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
|
|
251
347
|
// A source is named either in words, or the way careful writing actually does it: a link to
|
|
252
348
|
// somebody else's site sitting in the same paragraph as the figure it backs.
|
|
@@ -272,7 +368,7 @@ function analysePage(url, html) {
|
|
|
272
368
|
const citedParagraphs = paras.filter((x) => x.hrefs.some((h) => {
|
|
273
369
|
try { return new URL(h, url).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
|
|
274
370
|
})).length;
|
|
275
|
-
return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images:
|
|
371
|
+
return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: contentImgs.length + roleImgs.length, imagesNoAlt: imgsNoAlt, imagesAltMissing: imgsAltMissing, canonical, robots, viewport, og, twitter, generator, stack, htmlLang, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length, formFields, formDepth, ctaFirstScreen, priceShown, h2List, offerSentence, h3Count, trust };
|
|
276
372
|
}
|
|
277
373
|
|
|
278
374
|
/** Карты сайта на живых коммерческих сайтах бывают вложенными десятками: у одного московского
|
|
@@ -286,37 +382,81 @@ function analysePage(url, html) {
|
|
|
286
382
|
* Если остановились раньше, это возвращается наружу и попадает в отчёт словами. Выдать часть за
|
|
287
383
|
* целое нельзя: «1 200 адресов в карте сайта» на сайте с сорока тысячами адресов это неверное
|
|
288
384
|
* измерение, а не округление. */
|
|
289
|
-
async function readSitemap(startUrl, { maxFiles =
|
|
385
|
+
async function readSitemap(startUrl, { maxFiles = 50, budgetMs = 45000, maxUrls = 50000, timeout, concurrency = 4 } = {}) {
|
|
290
386
|
const deadline = Date.now() + budgetMs;
|
|
291
387
|
const seen = new Set();
|
|
292
388
|
const queue = [{ url: startUrl, depth: 0 }];
|
|
293
389
|
const urls = [];
|
|
294
390
|
let filesRead = 0; let nestedFound = 0; let stopped = '';
|
|
391
|
+
/*
|
|
392
|
+
* Вложенные карты читаем по несколько сразу. До 0.33.4 читали по одной и не больше шести файлов:
|
|
393
|
+
* у brainmd.com в индексе девять карт, и шестой лимит отрезал sitemap_articles_1.xml с 980
|
|
394
|
+
* адресами. Отчёт писал «в карте 349 страниц» при 1 357 настоящих (12.10.2026). Теперь лимит
|
|
395
|
+
* пятьдесят файлов, а время держит тот же бюджет.
|
|
396
|
+
*/
|
|
295
397
|
while (queue.length) {
|
|
296
398
|
if (filesRead >= maxFiles) { stopped = 'files'; break; }
|
|
297
399
|
if (Date.now() > deadline) { stopped = 'time'; break; }
|
|
298
400
|
if (urls.length >= maxUrls) { stopped = 'urls'; break; }
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
// Спускаться вглубь можно только из индекса карт. Прежнее условие из-за приоритета операторов
|
|
306
|
-
// читало `A || (B && C && D)` и уходило в рекурсию по любой ссылке на .xml, даже из обычного urlset.
|
|
307
|
-
const isIndex = /<sitemapindex/i.test(r.text);
|
|
308
|
-
for (const m of r.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)) {
|
|
309
|
-
const l = m[1];
|
|
310
|
-
if (isIndex && (/\.xml(\.gz)?$/i.test(l) || (/sitemap/i.test(l) && !/\.(html?|php)$/i.test(l)))) { nestedFound += 1; queue.push({ url: l, depth: depth + 1 }); }
|
|
311
|
-
else urls.push(l);
|
|
401
|
+
const batch = [];
|
|
402
|
+
while (queue.length && batch.length < Math.min(concurrency, maxFiles - filesRead)) {
|
|
403
|
+
const next = queue.shift();
|
|
404
|
+
if (seen.has(next.url) || next.depth > 3) continue;
|
|
405
|
+
seen.add(next.url);
|
|
406
|
+
batch.push(next);
|
|
312
407
|
}
|
|
408
|
+
if (!batch.length) continue;
|
|
409
|
+
const answers = await Promise.all(batch.map(({ url }) => getFollowed(url, timeout ? { timeout } : {})));
|
|
410
|
+
filesRead += batch.length;
|
|
411
|
+
batch.forEach(({ depth }, i) => {
|
|
412
|
+
const r = answers[i];
|
|
413
|
+
if (!r.ok) return;
|
|
414
|
+
// Спускаться вглубь можно только из индекса карт. Прежнее условие из-за приоритета операторов
|
|
415
|
+
// читало `A || (B && C && D)` и уходило в рекурсию по любой ссылке на .xml, даже из обычного urlset.
|
|
416
|
+
const isIndex = /<sitemapindex/i.test(r.text);
|
|
417
|
+
for (const m of r.text.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)) {
|
|
418
|
+
// В XML амперсанд записан как &: Shopify так пишет адреса вложенных карт (?from=..&to=..).
|
|
419
|
+
const l = m[1].replace(/&/g, '&');
|
|
420
|
+
if (isIndex && (/\.xml(\.gz)?(\?|$)/i.test(l) || (/sitemap/i.test(l) && !/\.(html?|php)$/i.test(l)))) { nestedFound += 1; queue.push({ url: l, depth: depth + 1 }); }
|
|
421
|
+
else urls.push(l);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
313
424
|
}
|
|
314
|
-
|
|
425
|
+
const pending = queue.filter((q) => !seen.has(q.url)).length;
|
|
426
|
+
if (!pending && stopped === 'files') stopped = '';
|
|
427
|
+
return { urls, filesRead, nestedFound, pending, stopped };
|
|
315
428
|
}
|
|
316
429
|
|
|
317
430
|
export const __readSitemap = readSitemap;
|
|
318
431
|
|
|
319
|
-
|
|
432
|
+
/** Адрес из карты сайта, который стоит читать как страницу. Shopify кладёт в карту agents.md, у
|
|
433
|
+
* других бывают PDF и картинки: это не страницы, и в выборке они только занимают место. */
|
|
434
|
+
export function isPageUrl(u) {
|
|
435
|
+
try { const x = new URL(u); return !NOT_A_PAGE_FILE.test(x.pathname) && !NOT_A_PAGE_PATH.test(x.pathname); } catch { return false; }
|
|
436
|
+
}
|
|
437
|
+
const NOT_A_PAGE_FILE = /\.(?!html?$|php$|aspx?$)[a-z0-9]{2,5}$/i;
|
|
438
|
+
const NOT_A_PAGE_PATH = /\/(cart|checkout|account|login|customer_authentication|customer_identity|wishlist)(\/|$)/i;
|
|
439
|
+
|
|
440
|
+
/** Как часто главная ссылается на каждую свою страницу: адрес (в виде norm) к числу ссылок. */
|
|
441
|
+
export function homeLinkRank(html, base, ownHosts) {
|
|
442
|
+
const rank = new Map();
|
|
443
|
+
const bareHost = (h) => h.replace(/^www\./, '').toLowerCase();
|
|
444
|
+
const own = new Set([...ownHosts].map(bareHost));
|
|
445
|
+
const src = String(html || '').replace(/<!--[\s\S]*?-->/g, ' ').replace(/<(script|style|noscript|template)\b[\s\S]*?<\/\1\s*>/gi, ' ');
|
|
446
|
+
for (const m of src.matchAll(/<a\b[^>]*?\shref\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
|
|
447
|
+
let u; try { u = new URL((m[1] ?? m[2]).replace(/&/g, '&'), base); } catch { continue; }
|
|
448
|
+
if (!/^https?:$/.test(u.protocol) || !own.has(bareHost(u.host))) continue;
|
|
449
|
+
u.hash = ''; u.search = '';
|
|
450
|
+
if (!u.pathname.includes('.') && !u.pathname.endsWith('/')) u.pathname += '/';
|
|
451
|
+
const k = u.href.toLowerCase().replace(/^https?:\/\/(www\.)?/, '');
|
|
452
|
+
rank.set(k, (rank.get(k) || 0) + 1);
|
|
453
|
+
}
|
|
454
|
+
return { has: (n) => rank.has(n.replace(/^https?:\/\/(www\.)?/, '')), get: (n) => rank.get(n.replace(/^https?:\/\/(www\.)?/, '')) || 0 };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3, preparedBy = '', visibility: givenVisibility = null, requestTimeout = 15000, sitemapBudgetMs = 45000, sitemapMaxFiles = 50, tier = '' } = {}) {
|
|
320
460
|
REQUEST_TIMEOUT = requestTimeout;
|
|
321
461
|
const opts = { backlinks };
|
|
322
462
|
const origin = new URL(startUrl).origin;
|
|
@@ -448,11 +588,13 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
448
588
|
log('llms.txt');
|
|
449
589
|
const llms = await getFollowed(`${siteOrigin}/llms.txt`);
|
|
450
590
|
const llmsIsText = llms.ok && !/<html/i.test(llms.text.slice(0, 500));
|
|
451
|
-
|
|
591
|
+
// Ссылки из markdown: хвостовые `, кавычки и точки не часть адреса. С ними brainmd.com/.well-known/ucp`
|
|
592
|
+
// считался мёртвым, а «brainmd.com.» чужим хостом (12.10.2026).
|
|
593
|
+
const llmsLinks = llmsIsText ? linksInText(llms.text) : [];
|
|
452
594
|
const llmsForeign = llmsLinks.filter((u) => { try { return !ownHosts.has(new URL(u).host); } catch { return false; } });
|
|
453
595
|
checks.push(row('llms', 'geo', 'llms.txt', !llms.ok ? 'warn' : !llmsIsText ? 'bad' : llmsForeign.length > llmsLinks.length / 2 && llmsLinks.length > 3 ? 'bad' : 'ok',
|
|
454
596
|
!llms.ok ? 'not present' : !llmsIsText ? 'answers with an HTML page, not a text index' : `${llmsLinks.length} link(s), ${llmsForeign.length} to other hosts`,
|
|
455
|
-
!llms.ok ? '
|
|
597
|
+
!llms.ok ? 'there is no short site map for AI agents, so they have to work through the pages themselves; generating one is a one-day job' : llmsForeign.length > 3 ? `links to ${[...new Set(llmsForeign.map((u) => new URL(u).host))].slice(0, 3).join(', ')}: an AI system may misidentify the business` : ''));
|
|
456
598
|
|
|
457
599
|
// Здоровье llms.txt: мы проверяем не только то, что файл есть, но и то, что он ведёт в живые
|
|
458
600
|
// страницы. Индекс для ИИ разъезжается тихо: страницу переименовали, закрыли от индексации или
|
|
@@ -490,7 +632,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
490
632
|
const agentCardOk = agentCard.ok && /^\s*\{/.test(agentCard.text || '');
|
|
491
633
|
checks.push(row('agent-card', 'geo', 'Agent card (/.well-known/agent.json)', agentCardOk ? 'ok' : 'warn',
|
|
492
634
|
agentCardOk ? 'present and valid JSON' : agentCard.ok ? 'present but not JSON' : 'not present',
|
|
493
|
-
agentCardOk ? '' : 'an early, optional standard:
|
|
635
|
+
agentCardOk ? '' : 'an early, optional standard: the file describes a software AI agent, if the business runs one; without such an agent it is not needed'));
|
|
494
636
|
|
|
495
637
|
const apiCatalog = await get(`${siteOrigin}/.well-known/api-catalog`);
|
|
496
638
|
checks.push(row('api-catalog', 'geo', 'API catalogue (RFC 9727)', apiCatalog.ok ? 'ok' : 'warn',
|
|
@@ -509,14 +651,24 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
509
651
|
log(`sampling up to ${pages} pages`);
|
|
510
652
|
const norm = (u) => { try { const x = new URL(u); x.hash = ''; x.search = ''; if (!x.pathname.includes('.') && !x.pathname.endsWith('/')) x.pathname += '/'; return x.href.toLowerCase(); } catch { return u; } };
|
|
511
653
|
const seenUrl = new Set([norm(home.final), norm(startUrl)]);
|
|
512
|
-
const rest = sitemapPages.filter((u) => { const n = norm(u); if (seenUrl.has(n)) return false; seenUrl.add(n); return true; });
|
|
654
|
+
const rest = sitemapPages.filter((u) => { if (!isPageUrl(u)) return false; const n = norm(u); if (seenUrl.has(n)) return false; seenUrl.add(n); return true; });
|
|
513
655
|
// The contact, about and policy pages are what the trust and conversion checks are about, so they
|
|
514
656
|
// are read first rather than left to the luck of sitemap order. A 44 page site sampled 20 deep was
|
|
515
657
|
// reporting "no messenger and no contact page" while both sat in the sitemap, unread.
|
|
516
658
|
const TRUST_PAGE = /\/(contact|contacts|contact-us|kontakt|kontakty|about|about-us|o-nas|team|company|privacy|privacy-policy|politika[a-z-]*|terms|terms-of-use|usloviya|oferta|legal|impressum)\/?$/i;
|
|
517
659
|
const isTrust = (u) => { try { return TRUST_PAGE.test(new URL(u).pathname); } catch { return false; } };
|
|
518
660
|
const trustFirst = rest.filter(isTrust);
|
|
519
|
-
|
|
661
|
+
/*
|
|
662
|
+
* После страниц доверия идут те, на которые ссылается главная, и чем чаще ссылается, тем раньше.
|
|
663
|
+
* Карта сайта перечисляет страницы в порядке движка, а не по важности: у магазина на Shopify
|
|
664
|
+
* это порядок заведения товаров, и в выборку brainmd.com попали книги, диски и MP3 вместо
|
|
665
|
+
* основных добавок. Отчёт написал «FAQPage не найдена», хотя она стоит на страницах добавок
|
|
666
|
+
* (12.10.2026). Главная показывает то, что сайт продаёт, поэтому по ней и выбираем.
|
|
667
|
+
*/
|
|
668
|
+
const homeRank = homeLinkRank(home.response.text || '', home.final, ownHosts);
|
|
669
|
+
const promoted = rest.filter((u) => !trustFirst.includes(u) && homeRank.has(norm(u)))
|
|
670
|
+
.sort((a, b) => homeRank.get(norm(b)) - homeRank.get(norm(a)));
|
|
671
|
+
const pool = [home.final, ...trustFirst, ...promoted, ...rest.filter((u) => !trustFirst.includes(u) && !promoted.includes(u))];
|
|
520
672
|
const wanted = pool.slice(0, pages);
|
|
521
673
|
/*
|
|
522
674
|
* Обход по ссылкам. Карта дала меньше страниц, чем нужно (или её нет вовсе): недостающие
|
|
@@ -599,7 +751,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
599
751
|
checks.push(row('og-title', 'technical', 'og:title on the sampled pages', onAll((p) => p.og.title) === good.length ? 'ok' : onAll((p) => p.og.title) ? 'warn' : 'bad',
|
|
600
752
|
`${onAll((p) => p.og.title)} of ${good.length} sampled page(s) carry og:title`, onAll((p) => p.og.title) === good.length ? '' : 'without it a shared link shows the raw URL or the wrong heading'));
|
|
601
753
|
checks.push(row('og-image', 'technical', 'og:image on the sampled pages', onAll((p) => p.og.image) === good.length ? 'ok' : onAll((p) => p.og.image) ? 'warn' : 'bad',
|
|
602
|
-
`${onAll((p) => p.og.image)} of ${good.length} sampled page(s) carry og:image`, onAll((p) => p.og.image) === good.length ? '' : '
|
|
754
|
+
`${onAll((p) => p.og.image)} of ${good.length} sampled page(s) carry og:image`, onAll((p) => p.og.image) === good.length ? '' : 'without og:image, messengers and social networks show the link without a picture'));
|
|
603
755
|
checks.push(row('twitter-card', 'technical', 'Twitter Card', hp.twitter ? 'ok' : 'warn', hp.twitter ? `twitter:card ${hp.twitter}` : 'no twitter:card on the homepage',
|
|
604
756
|
hp.twitter ? '' : 'X and several readers fall back to it when Open Graph is incomplete'));
|
|
605
757
|
checks.push(row('schema', 'aeo', 'Structured data on homepage', hp.schemaTypes.length ? (hp.schemaTypes.includes('(invalid JSON-LD)') ? 'warn' : 'ok') : 'warn', hp.schemaTypes.join(', ') || 'no JSON-LD'));
|
|
@@ -622,20 +774,31 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
622
774
|
withType('Product').length ? '' : 'only for pages that really sell a named thing with a price'));
|
|
623
775
|
checks.push(row('org-schema', 'geo', 'Organization or business schema', good.some((p) => p.schemaTypes.some((t) => /Organization|Corporation|NGO|EducationalOrganization|GovernmentOrganization|MedicalOrganization|LocalBusiness|RealEstateAgent|Dentist|Physician|Hospital|Pharmacy|VeterinaryCare|LegalService|Attorney|Notary|AccountingService|InsuranceAgency|FinancialService|BankOrCreditUnion|AutomotiveBusiness|AutoRepair|AutoDealer|HomeAndConstructionBusiness|Plumber|Electrician|RoofingContractor|HVACBusiness|HousePainter|Locksmith|MovingCompany|GeneralContractor|Restaurant|FoodEstablishment|CafeOrCoffeeShop|Bakery|BarOrPub|LodgingBusiness|Hotel|TravelAgency|HealthAndBeautyBusiness|BeautySalon|HairSalon|DaySpa|NailSalon|TattooParlor|SportsActivityLocation|HealthClub|Gym|ProfessionalService|Store|ClothingStore|GroceryStore|HardwareStore|FurnitureStore|JewelryStore|PetStore|ChildCare|Electrician|EmploymentAgency|SelfStorage|Library|Museum|EntertainmentBusiness|MedicalClinic|DentalClinic|Optician|PhysicalTherapy|NutritionService|EmergencyService|ITService|WebDesignService|MarketingAgency|AdvertisingAgency|SoftwareApplication/.test(t))) ? 'ok' : 'warn', good.some((p) => p.schemaTypes.some((t) => /Organization|Corporation|NGO|EducationalOrganization|GovernmentOrganization|MedicalOrganization|LocalBusiness|RealEstateAgent|Dentist|Physician|Hospital|Pharmacy|VeterinaryCare|LegalService|Attorney|Notary|AccountingService|InsuranceAgency|FinancialService|BankOrCreditUnion|AutomotiveBusiness|AutoRepair|AutoDealer|HomeAndConstructionBusiness|Plumber|Electrician|RoofingContractor|HVACBusiness|HousePainter|Locksmith|MovingCompany|GeneralContractor|Restaurant|FoodEstablishment|CafeOrCoffeeShop|Bakery|BarOrPub|LodgingBusiness|Hotel|TravelAgency|HealthAndBeautyBusiness|BeautySalon|HairSalon|DaySpa|NailSalon|TattooParlor|SportsActivityLocation|HealthClub|Gym|ProfessionalService|Store|ClothingStore|GroceryStore|HardwareStore|FurnitureStore|JewelryStore|PetStore|ChildCare|Electrician|EmploymentAgency|SelfStorage|Library|Museum|EntertainmentBusiness|MedicalClinic|DentalClinic|Optician|PhysicalTherapy|NutritionService|EmergencyService|ITService|WebDesignService|MarketingAgency|AdvertisingAgency|SoftwareApplication/.test(t))) ? 'present' : 'not found: answer engines have no entity to attach the site to'));
|
|
624
776
|
checks.push(row('resources', 'technical', 'Page weight (homepage)', hp.scripts > 30 ? 'bad' : hp.scripts > 15 ? 'warn' : 'ok', `${hp.scripts} script file(s), ${hp.stylesheets} stylesheet(s), ${hp.images} image(s)`));
|
|
625
|
-
if (hp.
|
|
777
|
+
if (hp.stack) checks.push(row('cms', 'overview', 'CMS', 'na', hp.generator ? hp.generator : `${hp.stack} (detected from the page code)`));
|
|
626
778
|
checks.push(row('hreflang', 'technical', 'hreflang', hp.hreflang ? 'ok' : 'na', hp.hreflang ? `${hp.hreflang} alternate(s)` : 'none (single language)'));
|
|
627
779
|
}
|
|
628
780
|
const imgs = good.reduce((a, p) => a + p.images, 0); const noAlt = good.reduce((a, p) => a + p.imagesNoAlt, 0);
|
|
629
781
|
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)`));
|
|
630
782
|
const multiH1 = good.filter((p) => p.h1Count > 1).length;
|
|
631
|
-
|
|
783
|
+
// Страницы вовсе без H1 тоже считаются (14.10.2026): на medeq-mc.ru у 11 из 20 страниц H1 не было, а отчёт писал «по одному H1 на страницу».
|
|
784
|
+
const noH1 = good.filter((p) => !p.h1Count).length;
|
|
785
|
+
if (good.length > 1) {
|
|
786
|
+
const h1Status = noH1 > good.length / 4 || multiH1 > good.length / 2 ? 'bad' : noH1 || multiH1 ? 'warn' : 'ok';
|
|
787
|
+
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';
|
|
788
|
+
checks.push(row('h1-sample', 'onpage', 'Heading structure across sample', h1Status, h1Value));
|
|
789
|
+
}
|
|
632
790
|
const titles = new Map(); for (const p of good) titles.set(p.title, (titles.get(p.title) || 0) + 1);
|
|
633
791
|
const dupTitles = [...titles].filter(([, n]) => n > 1);
|
|
634
792
|
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'));
|
|
635
793
|
const thin = good.filter((p) => p.words < 300);
|
|
636
|
-
if (good.length > 1) checks.push(row('thin', 'content', 'Thin pages in sample', thin.length > good.length / 2 ? 'bad' : thin.length ? 'warn' : 'ok', thin.length ? `${thin.length} of ${good.length} pages under 300 words` : `median ${median(good.map((p) => p.words))} words per page`));
|
|
794
|
+
if (good.length > 1) checks.push(row('thin', 'content', 'Thin pages 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`));
|
|
637
795
|
const utility = sitemapPages.filter((u) => /\/(shop|cart|checkout|wishlist|compare|my-account|tag|author|feed)\/?/i.test(new URL(u).pathname));
|
|
638
|
-
if (utility.length)
|
|
796
|
+
if (utility.length) {
|
|
797
|
+
// Пояснение называет только то, что нашлось: «корзина» у клиники без корзины выглядела копипастом (14.10.2026).
|
|
798
|
+
const KIND = [[/\/author\//i, 'author pages'], [/\/tag\//i, 'tag pages'], [/\/(cart|checkout)\b/i, 'cart and checkout pages'], [/\/wishlist|\/compare/i, 'wishlist pages'], [/\/my-account/i, 'account pages'], [/\/feed\b/i, 'feeds']];
|
|
799
|
+
const kinds = [...new Set(utility.flatMap((u) => KIND.filter(([re]) => re.test(new URL(u).pathname)).map(([, k]) => k)))];
|
|
800
|
+
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`));
|
|
801
|
+
}
|
|
639
802
|
const redirected = sample.filter((p) => p.redirect).length; const broken = sample.filter((p) => p.status && p.status >= 400).length;
|
|
640
803
|
if (sitemapPages.length) checks.push(row('sitemap-health', 'technical', 'Sitemap URLs that answer 200', broken ? 'bad' : redirected ? 'warn' : 'ok', `${sample.length - redirected - broken} of ${sample.length} sampled URLs answer 200${redirected ? `, ${redirected} redirect` : ''}${broken ? `, ${broken} broken` : ''}`));
|
|
641
804
|
if (good.length) {
|
|
@@ -677,14 +840,19 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
677
840
|
}
|
|
678
841
|
}
|
|
679
842
|
const dated = good.filter((p) => p.dates.modified || p.dates.published).length;
|
|
680
|
-
|
|
681
|
-
|
|
843
|
+
// Дата в разметке и дата, которую видит человек, это разные вещи (14.10.2026): Yoast ставит datePublished
|
|
844
|
+
// на каждую страницу, а на странице услуги даты может не быть вовсе. Считаем обе и пишем обе.
|
|
845
|
+
// Видимая дата засчитывается, только если совпадает с датой в разметке: дата лицензии или чужой новости в боковом блоке это не дата страницы.
|
|
846
|
+
const seen = good.filter((p) => p.dates.visibleMatch).length;
|
|
847
|
+
if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', dated ? (seen >= dated / 2 ? 'ok' : 'warn') : 'warn', dated ? `${dated} of ${good.length} sampled pages carry a date in the markup; on ${seen} the same date is visible on the page` : 'no article dates in the sample: answer engines cannot tell what is current'));
|
|
848
|
+
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')); }
|
|
682
849
|
|
|
683
850
|
// ---- Off-page and trust ----------------------------------------------------------------
|
|
684
851
|
// Link data comes from Semrush when SEMRUSH_API_KEY is set. Without it the three link rows are
|
|
685
852
|
// reported as not measured and the area is still scored on the trust signals, which are free.
|
|
686
853
|
log('off-page and trust');
|
|
687
|
-
|
|
854
|
+
// Главная часто входит и в выборку: без проверки адреса она считалась дважды, и выходило «21 из 21» при выборке в 20 (14.10.2026).
|
|
855
|
+
const pagesForTrust = hp && !good.some((p) => p.url === hp.url || p.url.replace(/\/$/, '') === String(hp.url).replace(/\/$/, '')) ? [hp, ...good] : good;
|
|
688
856
|
const anyTrust = (f) => pagesForTrust.some(f);
|
|
689
857
|
const links = await semrushBacklinks(host, log, opts.backlinks || null);
|
|
690
858
|
if (links.ok) {
|
|
@@ -692,7 +860,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
692
860
|
checks.push(row('links-profile', 'offpage', 'Backlink profile (optional extra)', 'na', `${links.domains} referring domain(s), ${links.total} link(s), ${followShare}% follow, authority score ${links.ascore} of 100`, `${links.source}. A paid tool, not required for this report and not counted in any score.`));
|
|
693
861
|
}
|
|
694
862
|
const addr = anyTrust((p) => p.trust?.postalAddress);
|
|
695
|
-
|
|
863
|
+
// Только город и страна это ещё не адрес (14.10.2026): полный адрес с улицей, иначе «спорно».
|
|
864
|
+
const street = anyTrust((p) => p.trust?.streetAddress);
|
|
865
|
+
checks.push(row('trust-entity', 'offpage', 'Who the business is, in machine-readable form', addr && street ? 'ok' : 'warn', addr && street ? 'a postal address is published in the structured data' : addr ? 'the structured data names only the city and country, no street address' : 'no postal address in the structured data', addr ? '' : 'rating systems and answer engines use it to tell one business from another'));
|
|
696
866
|
// A published messenger is a direct channel like any other. Counting only phone and email would
|
|
697
867
|
// mark a business that answers on Telegram all day as unreachable.
|
|
698
868
|
const tel = anyTrust((p) => p.trust?.telephone);
|
|
@@ -712,7 +882,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
712
882
|
const profileUrls = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].filter((u) => {
|
|
713
883
|
try { const x = new URL(u); return PROFILE_HOST.test(x.host.replace(/^www\./, '')) && x.pathname.replace(/\/+$/, '').length > 1; } catch { return false; }
|
|
714
884
|
});
|
|
715
|
-
checks.push(row('trust-profiles', 'offpage', 'Profiles the site claims elsewhere', profiles.length >= 2 ? 'ok' : profiles.length ? 'warn' : 'bad', profiles.length ? `${profiles.length}: ${profiles.slice(0, 5).join(', ')}` : 'no sameAs links: the site claims no profile anywhere else', profiles.length ? '' : '
|
|
885
|
+
checks.push(row('trust-profiles', 'offpage', 'Profiles the site claims elsewhere', profiles.length >= 2 ? 'ok' : profiles.length ? 'warn' : 'bad', profiles.length ? `${profiles.length}: ${profiles.slice(0, 5).join(', ')}` : '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'));
|
|
716
886
|
const about = anyTrust((p) => p.trust?.aboutPage);
|
|
717
887
|
checks.push(row('trust-about', 'offpage', 'A page that says who is behind the site', about ? 'ok' : 'warn', about ? 'linked from the sampled pages' : 'no about or team page is linked'));
|
|
718
888
|
const policies = [...new Set(pagesForTrust.flatMap((p) => p.trust?.policyPages || []))];
|
|
@@ -730,7 +900,11 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
730
900
|
if (depths.length) {
|
|
731
901
|
const md = median(depths);
|
|
732
902
|
checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', md < 50 ? 'ok' : md < 80 ? 'warn' : 'bad', `median ${md}% of the way down the page, measured on ${depths.length} page(s)`, md >= 80 ? 'on a long page almost nobody scrolls that far, least of all on a phone' : ''));
|
|
733
|
-
} else
|
|
903
|
+
} else {
|
|
904
|
+
// Формы нет, но заявки идут через мессенджеры: это не провал, а другой способ (14.10.2026).
|
|
905
|
+
const viaMsg = pagesForTrust.filter((p) => p.messengerLinks).length;
|
|
906
|
+
checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', withForm.length || viaMsg ? 'na' : 'bad', withForm.length ? 'position could not be measured' : viaMsg ? `no form; requests go through messenger links on ${viaMsg} of ${pagesForTrust.length} sampled page(s)` : 'no form on the sampled pages'));
|
|
907
|
+
}
|
|
734
908
|
const fields = withForm.map((p) => p.formFields).filter((n) => n > 0);
|
|
735
909
|
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', `median ${mf} field(s) before a visitor can send anything`, mf > 4 ? 'every extra field costs replies, and a phone number costs the most' : '')); }
|
|
736
910
|
const msg = pagesForTrust.filter((p) => p.messengerLinks > 0);
|
|
@@ -813,7 +987,8 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
813
987
|
const ru = lang === 'ru';
|
|
814
988
|
const pagesRu = (n) => (n % 10 === 1 && n % 100 !== 11 ? 'страница' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'страницы' : 'страниц');
|
|
815
989
|
const S = ru ? {
|
|
816
|
-
rows: ['Движок сайта', 'Страниц в карте сайта', 'Язык', '
|
|
990
|
+
rows: ['Движок сайта', 'Страниц в карте сайта', 'Язык', 'Чем занимается'],
|
|
991
|
+
partial: (n, read, total) => `не меньше ${n}: прочитано ${read} из ${total} файлов карты, остальные не успели`,
|
|
817
992
|
site: 'Сам сайт', free: 'Бесплатно, без аккаунта',
|
|
818
993
|
siteDetail: (n, viaLinks) => `robots.txt, карты сайта, llms.txt, главная и ${n} ${pagesRu(n)} выборки, прочитанные так же, как их читает любой посетитель${viaLinks ? `; ${viaLinks} из них найдены по ссылкам с главной, потому что в карте сайта их слишком мало` : ''}`,
|
|
819
994
|
paid: 'Платный сервис, по желанию', paidDetail: 'только для справки, ни в одну оценку не входит',
|
|
@@ -824,6 +999,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
824
999
|
conv: ['Форма связи', 'Цена видна', 'Мессенджеры'],
|
|
825
1000
|
} : {
|
|
826
1001
|
rows: ['CMS / stack', 'Pages in sitemap', 'Language', 'What is sold'],
|
|
1002
|
+
partial: (n, read, total) => `at least ${n}: ${read} of ${total} sitemap files were read, the rest were not reached`,
|
|
827
1003
|
site: 'The site itself', free: 'Free, no account needed',
|
|
828
1004
|
siteDetail: (n, viaLinks) => `robots.txt, the sitemaps, llms.txt, the homepage and ${n} sampled page(s), read exactly as any visitor reads them${viaLinks ? `; ${viaLinks} of the pages were found by following links from the homepage, because the sitemap listed too few` : ''}`,
|
|
829
1005
|
paid: 'Paid tool, optional', paidDetail: 'reported for context only, not counted in any score',
|
|
@@ -836,14 +1012,14 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
836
1012
|
const criticalShown = ru ? localised.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' })) : critical;
|
|
837
1013
|
|
|
838
1014
|
return {
|
|
839
|
-
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.
|
|
1015
|
+
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 || '') : '' },
|
|
840
1016
|
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' },
|
|
841
1017
|
overall,
|
|
842
1018
|
reportScore,
|
|
843
1019
|
scores,
|
|
844
1020
|
scoreBasis,
|
|
845
1021
|
summary: { lead: '{{Three sentences: what the site is, what works, what holds it back.}}', verdict: '{{Key takeaway in three sentences, ending with how fast the critical issues can be fixed.}}', priorities: ['{{Priority one}}', '{{Priority two}}', '{{Priority three}}'] },
|
|
846
|
-
overview: { rows: [[S.rows[0], hp?.generator || '{{stack}}'], [S.rows[1], String(sitemapPages.length)], [S.rows[2], '{{language}}'], [S.rows[3], '{{products}}']], note: '{{One paragraph on how the offer is structured on the site and whether it is clear.}}' },
|
|
1022
|
+
overview: { rows: [[S.rows[0], hp?.generator || hp?.stack || '{{stack}}'], [S.rows[1], sitemapRead.stopped ? S.partial(sitemapPages.length, sitemapRead.filesRead, sitemapRead.filesRead + sitemapRead.pending) : String(sitemapPages.length)], [S.rows[2], '{{language}}'], [S.rows[3], '{{products}}']], note: '{{One paragraph on how the offer is structured on the site and whether it is clear.}}' },
|
|
847
1023
|
critical: criticalShown,
|
|
848
1024
|
checks: localised,
|
|
849
1025
|
content: { strengths: ['{{strength}}'], weaknesses: ['{{weakness}}'], gaps: ['{{missing landing page or cluster}}'] },
|
|
@@ -867,7 +1043,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
867
1043
|
reading,
|
|
868
1044
|
/* Сырьё для готовых файлов: профили, которые сайт сам про себя заявляет, и есть ли контакты. */
|
|
869
1045
|
entity: { profiles: profileUrls, hosts: profiles, telephone: Boolean(hp?.trust?.telephone), email: Boolean(hp?.trust?.email) },
|
|
870
|
-
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, 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 } : p),
|
|
1046
|
+
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),
|
|
871
1047
|
notes,
|
|
872
1048
|
};
|
|
873
1049
|
}
|