@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.33.3",
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
- const h1s = root.querySelectorAll('h1').map((h) => h.text.trim()).filter(Boolean);
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
- const imgsNoAlt = imgs.filter((i) => !(i.getAttribute('alt') || '').trim()).length;
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
- const ld = root.querySelectorAll('script[type="application/ld+json"]').map((x) => x.text).join('\n');
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|связ|заяв|консульт|оставить|закаж|закажите|купить|узнать цен|проверить сайт|начать/i;
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|&|&amp;) 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
- const firstScreen = bodyInner.slice(0, Math.max(1200, Math.round(bodyInner.length * 0.15)));
230
- const ctaFirstScreen = (firstScreen.match(/<a[^>]*>[\s\S]*?<\/a>|<button[^>]*>[\s\S]*?<\/button>/gi) || [])
231
- .some((el) => CONTACT_INTENT.test(el.replace(/<[^>]+>/g, ' ')) || CONTACT_INTENT.test(el));
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
- const firstPara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
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: imgs.length, imagesNoAlt: imgsNoAlt, canonical, robots, viewport, og, twitter, generator, 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, trust };
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 = 6, budgetMs = 45000, maxUrls = 5000, timeout } = {}) {
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 { url, depth } = queue.shift();
300
- if (seen.has(url) || depth > 3) continue;
301
- seen.add(url);
302
- const r = await getFollowed(url, timeout ? { timeout } : {});
303
- filesRead++;
304
- if (!r.ok) continue;
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 амперсанд записан как &amp;: Shopify так пишет адреса вложенных карт (?from=..&amp;to=..).
419
+ const l = m[1].replace(/&amp;/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
- return { urls, filesRead, nestedFound, pending: queue.length, stopped };
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
- 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 = 6, tier = '' } = {}) {
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(/&amp;/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
- const llmsLinks = llmsIsText ? [...llms.text.matchAll(/https?:\/\/[^\s)\]]+/g)].map((m) => m[0]) : [];
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 ? 'answer engines get nothing to read; a generated index 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` : ''));
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: it is the file agent directories read to learn who you are and what you offer'));
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
- const pool = [home.final, ...trustFirst, ...rest.filter((u) => !trustFirst.includes(u))];
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 ? '' : 'a link without a picture is scrolled past in every messenger'));
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.generator) checks.push(row('cms', 'overview', 'CMS', 'na', hp.generator));
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
- if (good.length > 1) checks.push(row('h1-sample', 'onpage', 'Heading structure across sample', multiH1 ? (multiH1 > good.length / 2 ? 'bad' : 'warn') : 'ok', multiH1 ? `${multiH1} of ${good.length} pages carry more than one H1` : 'one H1 per page'));
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) checks.push(row('utility', 'technical', 'Utility pages in the sitemap', 'warn', `${utility.length} URL(s) such as ${new URL(utility[0]).pathname}`, 'cart, wishlist, tag and author pages add noise to the index'));
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
- if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', dated ? 'ok' : 'warn', dated ? `${dated} of ${good.length} sampled pages expose dates` : 'no article dates in the sample: answer engines cannot tell what is current'));
681
- if (/wordpress/i.test(hp?.generator || '')) { 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')); }
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
- const pagesForTrust = hp ? [hp, ...good] : good;
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
- checks.push(row('trust-entity', 'offpage', 'Who the business is, in machine-readable form', addr ? 'ok' : 'warn', addr ? 'a postal address is published in the structured data' : 'no postal address in the structured data', addr ? '' : 'rating systems and answer engines use it to tell one business from another'));
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 ? '' : 'the cheapest off-site signal there is, and it is free'));
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 checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', withForm.length ? 'na' : 'bad', withForm.length ? 'position could not be measured' : 'no form on the sampled pages'));
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.og?.locale || '') : '' },
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
  }