@operstack/audit 0.35.0 → 0.36.2

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.35.0",
3
+ "version": "0.36.2",
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",
@@ -34,7 +34,7 @@
34
34
  "LICENSE"
35
35
  ],
36
36
  "scripts": {
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 && node src/test-planted.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 && node src/test-planted.mjs && node src/test-presentation.mjs && node src/test-answer-first.mjs",
38
38
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
39
39
  "verify:live": "node src/verify-live.mjs"
40
40
  },
package/src/collect.mjs CHANGED
@@ -8,6 +8,7 @@ import { dirname, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { renderedDom, compareReadings, jsBlindnessCheck } from './rendered.mjs';
10
10
  import { parse } from 'node-html-parser';
11
+ import { profileLine } from './i18n.mjs';
11
12
  import { localiseChecks } from './i18n.mjs';
12
13
  import { checkVisibility, VISIBILITY_DEFAULTS, internalLinks, robotsAllows, linksInText } from './visibility.mjs';
13
14
 
@@ -33,7 +34,7 @@ async function get(url, { method = 'GET', timeout = REQUEST_TIMEOUT } = {}) {
33
34
  const text = method === 'GET' ? await res.text() : '';
34
35
  // Заголовок Link нужен проверке «есть ли markdown-версия страницы»: им сайт говорит агенту,
35
36
  // где лежит текстовая копия. Возвращаем его сразу, чтобы не ходить за страницей второй раз.
36
- return { ok: res.ok, status: res.status, location: res.headers.get('location'), type: res.headers.get('content-type') || '', link: res.headers.get('link') || '', text, ttfb, url };
37
+ return { ok: res.ok, status: res.status, location: res.headers.get('location'), type: res.headers.get('content-type') || '', link: res.headers.get('link') || '', poweredBy: res.headers.get('x-powered-by') || '', text, ttfb, url };
37
38
  } catch (e) { return { ok: false, status: 0, error: e.name === 'AbortError' ? 'timeout' : e.message, text: '', ttfb: Date.now() - started, url }; }
38
39
  finally { clearTimeout(t); }
39
40
  }
@@ -159,6 +160,9 @@ const SOURCE_RE = /(?<!\p{L})(according to|source:|sources:|data from|reported b
159
160
  * отчёт о магазине на Shopify писал «CMS: не измерено», хотя в коде стоят Shopify.shop и
160
161
  * cdn.shopify.com (brainmd.com, 12.10.2026). Только надёжные следы: лучше промолчать, чем угадать.
161
162
  */
163
+ /** Пороги статусов в одном месте (14.10.2026): «Норма» по датам только если датированы хотя бы 3 страницы выборки или половина. */
164
+ export const DATES_OK_MIN = 3;
165
+
162
166
  const STACK_SIGNS = [
163
167
  ['Shopify', /\bShopify\.shop\b|cdn\.shopify\.com|\/cdn\/shop\/|myshopify\.com/i],
164
168
  ['WordPress', /\/wp-content\/|\/wp-includes\//i],
@@ -182,7 +186,7 @@ export function detectStack(html) {
182
186
  return hit ? hit[0] : '';
183
187
  }
184
188
 
185
- function analysePage(url, html) {
189
+ export function analysePage(url, html) {
186
190
  const root = parse(html, { blockTextElements: { script: true, style: true, noscript: true } });
187
191
  const attr = (sel, a) => root.querySelector(sel)?.getAttribute(a) ?? '';
188
192
  const title = root.querySelector('title')?.text.trim() ?? '';
@@ -225,6 +229,9 @@ function analysePage(url, html) {
225
229
  walk(j);
226
230
  } catch { schemaTypes.push('(invalid JSON-LD)'); }
227
231
  }
232
+ // Микроразметка schema.org (itemtype) это та же разметка, что JSON-LD: Битрикс и Аспро ставят хлебные крошки и
233
+ // товары только так. До 14.10.2026 движок её не видел и писал cimes.ru «хлебных крошек нет» при BreadcrumbList на всех страницах.
234
+ for (const el of root.querySelectorAll('[itemtype]')) for (const t of String(el.getAttribute('itemtype') || '').split(/\s+/)) { const m = /schema\.org\/([A-Za-z]+)/.exec(t); if (m) schemaTypes.push(m[1]); }
228
235
  // Служебная обвязка вырезается, кроме <header>, в котором стоит H1: это шапка статьи, а не сайта.
229
236
  const bodyHtml = (html.match(/<body[\s\S]*<\/body>/i) || [html])[0]
230
237
  .replace(/<(nav|footer|aside|script|style)[\s\S]*?<\/\1>/gi, ' ')
@@ -258,24 +265,39 @@ function analysePage(url, html) {
258
265
  // Conversion path, read from the page itself. No analytics is needed to see whether a visitor
259
266
  // who wants to talk to this business can find a way to do it, and how far down it is.
260
267
  const bodyInner = body?.innerHTML || html;
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;
268
+ const CONTACT_INTENT = /contact|enquir|inquir|get in touch|request a|book a|call us|consult|get a quote|free quote|order (the|an|a|your)|buy |buy now|add to (cart|bag|basket)|shop (now|all|the)|take the quiz|start the quiz|subscribe (and|&|&amp;) save|get started|start (here|free|now)|try it|check (your|a) site|audit|pricing|связ|заяв|консульт|оставить|оставьте|закаж|закажите|заказать|купить|в корзину|добавить в корзину|узнать цен|узнать стоимост|проверить сайт|начать|обсудить|обсудим|позвонить|перезвон|рассчитать|расч[её]т|записаться|запишитесь|получить (?:предложение|кп|консультац|расч)|callback-|order-btn|request-btn/i;
269
+ // «Обсудить проект», «Заказать», «Рассчитать стоимость» тоже призыв: cimes.ru 14.10.2026 отчёт писал «на первом экране нечего нажать».
262
270
  const hrefsLower = links.map((h) => h.toLowerCase());
263
271
  const telLinks = hrefsLower.filter((h) => h.startsWith('tel:')).length;
264
272
  const mailLinks = hrefsLower.filter((h) => h.startsWith('mailto:')).length;
265
273
  const messengerLinks = hrefsLower.filter((h) => /(wa\.me|api\.whatsapp\.com|t\.me\/|telegram\.me|m\.me\/|viber:|signal\.me)/.test(h)).length;
266
274
  const contactPages = hrefsLower.filter((h) => /\/(contact|contacts|contact-us|kontakt|svyaz|contacto)\/?($|[?#])/.test(h)).length;
267
275
  const formEls = root.querySelectorAll('form');
268
- const realForms = formEls.filter((f) => !/search/i.test(`${f.getAttribute('class') || ''} ${f.getAttribute('id') || ''} ${f.getAttribute('role') || ''} ${f.getAttribute('action') || ''}`));
276
+ // Не форма заявки: поиск, окно настроек cookie и форма без полей для текста, телефона или почты (cimes.ru 14.10.2026:
277
+ // «форма на 86 процентах страницы» оказалась окном cookie, а заявка открывается кнопкой в шапке).
278
+ const realForms = formEls.filter((f) => !/search|cookie|consent|gdpr/i.test(`${f.getAttribute('class') || ''} ${f.getAttribute('id') || ''} ${f.getAttribute('role') || ''} ${f.getAttribute('action') || ''} ${f.getAttribute('name') || ''}`)
279
+ && !f.querySelectorAll('input').some((i) => /^search$/i.test(i.getAttribute('type') || '') || /^(q|s|search|query)$/i.test(i.getAttribute('name') || ''))
280
+ && (f.querySelectorAll('textarea').length || f.querySelectorAll('input').some((i) => /^(text|tel|email|number)?$/i.test(i.getAttribute('type') || ''))));
269
281
  // Виджет записи без тега <form> (поля телефона и имени в обычном блоке) это тоже форма (14.10.2026, neronov.pro).
270
282
  if (!realForms.length) {
271
283
  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
284
  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
285
  }
274
- const firstForm = realForms[0] || null;
286
+ // Форма во всплывающем окне (заявка по кнопке) стоит в разметке в конце страницы, но посетитель её не листает:
287
+ // её глубину не мерим, а отмечаем отдельно (cimes.ru 14.10.2026: «форма на 96 процентах» была окном «Обсудить проект»).
288
+ const attrs = (n) => `${n.getAttribute('class') || ''} ${n.getAttribute('id') || ''}`;
289
+ // Скрытый блок (hidden, display:none, aria-hidden) посетитель не видит: это не форма. На cimes.ru поле телефона
290
+ // стояло в скрытом блоке виджета ИИ-чата. Класс оформления «popup-content» у видимой формы контактов окном не считаем.
291
+ const hiddenBox = (el) => { for (let n = el; n; n = n.parentNode) { if (typeof n.getAttribute !== 'function') break; const h = n.getAttribute('hidden'); if ((h !== undefined && h !== null) || n.getAttribute('aria-hidden') === 'true' || /display:\s*none/i.test(n.getAttribute('style') || '') || /(^|\s)(hidden|d-none|mfp-hide|is-hidden)(\s|$)/i.test(n.getAttribute('class') || '')) return true; } return false; };
292
+ const inPopup = (el) => { for (let n = el; n; n = n.parentNode) { if (typeof n.getAttribute !== 'function') break; if (/^dialog$/i.test(n.tagName || '') || n.getAttribute('role') === 'dialog' || /(^|\s)(modal|popup|popup-window|modal-window|fancybox|lightbox|callback-popup|js-popup)(\s|$)/i.test(attrs(n))) return true; } return false; };
293
+ for (let i = realForms.length - 1; i >= 0; i--) if (hiddenBox(realForms[i])) realForms.splice(i, 1);
294
+ const popupForms = realForms.filter(inPopup);
295
+ const firstForm = realForms.find((f) => !inPopup(f)) || null;
275
296
  // A honeypot is invisible to a person, so counting it as a field the visitor must fill is wrong.
276
297
  const visibleToVisitor = (el) => {
277
298
  if (el.getAttribute('tabindex') === '-1') return false;
278
- if (/honeypot|\bhp\b/i.test(`${el.getAttribute('class') || ''} ${el.getAttribute('name') || ''}`)) return false;
299
+ // Невидимая ловушка для спам-ботов (class «inv», «visually-hidden») не поле для посетителя: cimes.ru 14.10.2026 «6 полей» при 4 видимых.
300
+ if (/honeypot|\bhp\b|(^|\s)(inv|invisible|visually-hidden|sr-only|d-none|hidden)(\s|$)/i.test(`${el.getAttribute('class') || ''} ${el.getAttribute('name') || ''}`)) return false;
279
301
  for (let n = el.parentNode; n; n = n.parentNode) {
280
302
  if (typeof n.getAttribute !== 'function') break;
281
303
  if (n.getAttribute('aria-hidden') === 'true') return false;
@@ -284,7 +306,7 @@ function analysePage(url, html) {
284
306
  }
285
307
  return true;
286
308
  };
287
- const formFields = firstForm ? firstForm.querySelectorAll('input, textarea, select').filter((i) => !/^(hidden|submit|button|image)$/i.test(i.getAttribute('type') || '')).filter(visibleToVisitor).length : 0;
309
+ const formFields = firstForm ? firstForm.querySelectorAll('input, textarea, select').filter((i) => !/^(hidden|submit|button|image|checkbox|radio|reset)$/i.test(i.getAttribute('type') || '')).filter(visibleToVisitor).length : 0;
288
310
  const formAt = firstForm ? bodyInner.indexOf(firstForm.outerHTML.slice(0, 120)) : -1;
289
311
  const formDepth = firstForm && formAt >= 0 && bodyInner.length ? Math.round((formAt / bodyInner.length) * 100) : null;
290
312
  /*
@@ -327,20 +349,36 @@ function analysePage(url, html) {
327
349
  email: /"email"\s*:/.test(ld) || mailLinks > 0,
328
350
  sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
329
351
  sameAsUrls: [...new Set(sameAs)],
330
- aboutPage: hrefsLower.some((h) => /\/(about|about-us|our-story|team|company|o-nas)\/?($|[?#])/.test(h)),
352
+ // Карточки на картах и в каталогах, которые сайт показывает виджетом или ссылкой: у cimes.ru в подвале рейтинг
353
+ // Яндекс Карт (sprav/widget/rating-badge/1215897959), а отчёт писал «профилей на площадках нет» (15.10.2026).
354
+ listings: [...new Set([
355
+ ...[...String(html).matchAll(/yandex\.(?:ru|com)\/(?:sprav\/widget\/rating-badge|maps\/org(?:\/[^"'\s/]+)?)\/(\d{6,})/gi)].map((m) => `https://yandex.ru/maps/org/${m[1]}/`),
356
+ ...[...String(html).matchAll(/2gis\.ru\/[^"'\s]*?\/firm\/(\d{6,})/gi)].map((m) => `https://2gis.ru/firm/${m[1]}`),
357
+ ])],
358
+ // Только ссылки внутри своего сайта: «yandex.ru/support/.../about» засчитывался cimes.ru как страница о компании (14.10.2026).
359
+ aboutPage: hrefsLower.some((h) => { let own = false; try { own = !/^[a-z]+:/.test(h) && !h.startsWith('//') ? true : new URL(h, url).host.replace(/^www\./, '') === new URL(url).host.replace(/^www\./, ''); } catch { own = false; } return own && /\/(about|about-us|about-company|our-story|team|company|o-nas|o-kompanii|kompaniya)\/?($|[?#])/.test(h); }),
331
360
  policyPages: [...new Set(hrefsLower.flatMap(policyKinds))],
332
361
  author: /"author"\s*:/.test(ld),
333
362
  };
334
363
  // Answer-first family, the same measures the free AI visibility check uses, so the two agree.
335
364
  const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
336
365
  // Без H1 первый абзац берём из тела страницы: иначе у главной без H1 «текста нет» (14.10.2026, medeq-mc.ru).
337
- const firstPara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1])
366
+ // Вводный блок после H1 это все абзацы до первого подзаголовка, кнопки или формы. На cimes.ru (15.10.2026) ответ стоял
367
+ // двумя абзацами: описание на 19 слов и сразу «Визитка от 28 000 ₽ · … Смета за 1–2 дня» в p.hero-prices, а движок
368
+ // брал только первый <p> и писал «ответа с цифрой нет». Блок вместе укладывается в 20-90 слов: он и есть первый абзац.
369
+ const leadZone = afterH1.split(/<h[2-6]\b|<button\b|<form\b|<a\b[^>]*class=["'][^"']*\bbtn\b/i)[0];
370
+ const leadParas = [...leadZone.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => plain(m[1])).filter(Boolean);
371
+ const wordsOf = (t) => (String(t).match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
372
+ const leadBlock = leadParas.join(' ');
373
+ const onePara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
374
+ const firstPara = (leadParas.length > 1 && wordsOf(onePara) < 20 && wordsOf(leadBlock) <= 90 ? leadBlock : onePara)
338
375
  || ([...bodyHtml.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => plain(m[1])).find((t) => (t.match(/[\p{L}]+/gu) || []).length >= 12) || '');
339
376
  // Фраза, где сайт сам говорит, чем занимается: для строки «Чем занимается» при пустом описании (14.10.2026).
340
377
  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
378
  const h2List = [...bodyHtml.matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi)].map((m) => plain(m[1]).trim()).filter(Boolean).slice(0, 10);
342
379
  const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
343
- const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && /\d/.test(firstPara);
380
+ // Цифра ответа это число, а не часть слова: «HTML5, CSS3» и «1С» цифрой не считаются (cimes.ru, 15.10.2026).
381
+ const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && /(?<![\p{L}\d])\d+(?:[\s.,]\d+)*(?![\p{L}\d])/u.test(firstPara);
344
382
  const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
345
383
  const h3Count = (bodyHtml.match(/<h3[\s>]/gi) || []).length;
346
384
  const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
@@ -368,7 +406,7 @@ function analysePage(url, html) {
368
406
  const citedParagraphs = paras.filter((x) => x.hrefs.some((h) => {
369
407
  try { return new URL(h, url).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
370
408
  })).length;
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 };
409
+ return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: contentImgs.length + roleImgs.length, imagesNoAlt: imgsNoAlt, imagesAltMissing: imgsAltMissing, canonical, robots, viewport, og, twitter, generator, stack, htmlLang, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length - popupForms.length, formPopup: popupForms.length > 0, formFields, formDepth, ctaFirstScreen, priceShown, h2List, offerSentence, h3Count, trust };
372
410
  }
373
411
 
374
412
  /** Карты сайта на живых коммерческих сайтах бывают вложенными десятками: у одного московского
@@ -511,7 +549,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
511
549
 
512
550
  const alt = await follow(`${new URL(home.final).protocol}//${altHost}/`);
513
551
  const altOk = alt.final.replace(/\/$/, '') === new URL(home.final).origin.replace(/\/$/, '') || alt.hops.some((h) => h.status >= 300 && h.status < 400);
514
- checks.push(row('www', 'technical', 'www canonicalisation', alt.response.status === 0 ? 'warn' : altOk ? 'ok' : 'warn', alt.response.status === 0 ? `${altHost} does not resolve` : altOk ? `${altHost} redirects to ${new URL(home.final).host}` : `${altHost} answers ${alt.response.status} without redirecting`));
552
+ // Нет кода ответа (обрыв, долгое ожидание) это «не измерено», а не «не перенаправляет»: cimes.ru 14.10.2026 отвечал 301, отчёт писал обратное.
553
+ const altCode = Number(alt.response.status);
554
+ checks.push(row('www', 'technical', 'www canonicalisation', alt.response.status === 0 ? 'warn' : altOk ? 'ok' : !altCode ? 'na' : 'warn', alt.response.status === 0 ? `${altHost} does not resolve` : altOk ? `${altHost} redirects to ${new URL(home.final).host}` : !altCode ? `${altHost} did not answer in time, not measured` : `${altHost} answers ${altCode} without redirecting`));
515
555
  checks.push(row('ttfb', 'technical', 'Time to first byte (homepage)', home.response.ttfb > 2500 ? 'bad' : home.response.ttfb > 1200 ? 'warn' : 'ok', `${home.response.ttfb} ms`, 'single request from the auditor, not a lab test'));
516
556
 
517
557
  log('robots.txt');
@@ -713,6 +753,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
713
753
  const reading = { wanted: wanted.length, read: sample.filter((x) => x.title !== undefined).length, stoppedAt, gapMs: gap, crawlDelay: politeness, fromSitemap: wanted.length - 1 - fromLinks, fromLinks };
714
754
  const good = sample.filter((p) => p.title !== undefined);
715
755
  const hp = homePage || good[0];
756
+ const poweredBy = (() => { const v = String(home.response?.poweredBy || '').trim(); return v && !/^(php|asp\.net|express|next\.js|nuxt|plesk|servlet|jsp|perl|mono|kestrel|hhvm|openresty|nginx|apache|w3 total cache|wp engine)/i.test(v) ? v.slice(0, 60) : ''; })();
716
757
 
717
758
  if (hp) {
718
759
  // Ноль в этих трёх строках означает «элемента нет», а не «маленькое значение». Строка
@@ -775,6 +816,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
775
816
  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'));
776
817
  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)`));
777
818
  if (hp.stack) checks.push(row('cms', 'overview', 'CMS', 'na', hp.generator ? hp.generator : `${hp.stack} (detected from the page code)`));
819
+ // Своя система сайта называет себя в заголовке X-Powered-By: cimes.ru отвечает «Cimes CMS» (14.10.2026). Ссылки вида
820
+ // /bitrix/ на главной у веб-студии это разделы услуг, а не след движка, поэтому по ним не угадываем.
821
+ else if (poweredBy) checks.push(row('cms', 'overview', 'CMS', 'na', `${poweredBy} (from the X-Powered-By server header)`));
778
822
  checks.push(row('hreflang', 'technical', 'hreflang', hp.hreflang ? 'ok' : 'na', hp.hreflang ? `${hp.hreflang} alternate(s)` : 'none (single language)'));
779
823
  }
780
824
  const imgs = good.reduce((a, p) => a + p.images, 0); const noAlt = good.reduce((a, p) => a + p.imagesNoAlt, 0);
@@ -794,7 +838,8 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
794
838
  const LISTY = /\/(articles|blog|news|novosti|stati|otzyvy|reviews|testimonials|category|tag|page\/\d+)\/?$/i;
795
839
  const thin = good.filter((p) => p.words < 300 && !LISTY.test(new URL(p.url).pathname));
796
840
  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`));
797
- const utility = sitemapPages.filter((u) => /\/(shop|cart|checkout|wishlist|compare|my-account|tag|author|feed)\/?/i.test(new URL(u).pathname));
841
+ // «shop» это витрина, а не служебная страница: cimes.ru/solutions/shop/ продающий каталог (14.10.2026). Только целый сегмент пути.
842
+ const utility = sitemapPages.filter((u) => /\/(cart|checkout|wishlist|compare|my-account|tag|author|feed)(\/|$)/i.test(new URL(u).pathname));
798
843
  if (utility.length) {
799
844
  // Пояснение называет только то, что нашлось: «корзина» у клиники без корзины выглядела копипастом (14.10.2026).
800
845
  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']];
@@ -846,7 +891,17 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
846
891
  // на каждую страницу, а на странице услуги даты может не быть вовсе. Считаем обе и пишем обе.
847
892
  // Видимая дата засчитывается, только если совпадает с датой в разметке: дата лицензии или чужой новости в боковом блоке это не дата страницы.
848
893
  const seen = good.filter((p) => p.dates.visibleMatch).length;
849
- 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'));
894
+ // Даты нужны статьям, новостям и кейсам. Нет таких страниц в выборке: оценивать нечего, а не «Частично» (cimes.ru, 14.10.2026).
895
+ const ARTICLE = /\/(blog|news|novosti|stati|statya|article|articles|post|posts|journal|zhurnal|media|press|kejsy?|cases?|portfolio|insights|guides?)\/[^/]+/i;
896
+ const NOT_ARTICLE = /\/(contact|contacts|kontakty?|about|o-nas|o-kompanii|uslugi|services?|price|ceny|prajs|policy|politika|soglasie|cart|checkout)(\/|$)/i;
897
+ const articleLike = good.filter((p) => { const path = new URL(p.url).pathname; return ARTICLE.test(path) || (p.schemaTypes || []).some((t) => /^(Article|BlogPosting|NewsArticle|Report|MedicalWebPage)$/.test(t)) || (p.dates?.published && !NOT_ARTICLE.test(path) && path !== '/'); });
898
+ // Виды считаем отдельно и называем только те, что есть: «статьи блога и новости, 271» было 231 статьёй и 40 работами
899
+ // портфолио, а новостей у cimes.ru нет вовсе (15.10.2026).
900
+ const kindCount = (re) => sitemapPages.filter((u) => { try { const p = new URL(u).pathname; return re.test(p) && ARTICLE.test(p); } catch { return false; } }).length;
901
+ const notSampled = { blog: kindCount(/\/(blog|stati|statya|article|articles|post|posts|journal|zhurnal|insights|guides?)\//i), news: kindCount(/\/(news|novosti|press|media)\//i), cases: kindCount(/\/(kejsy?|cases?|portfolio)\//i) };
902
+ const notSampledLine = Object.entries(notSampled).filter(([, n]) => n).map(([k, n]) => `${k} ${n}`).join(', ');
903
+ if (good.length > 2 && !articleLike.length) checks.push(row('dates', 'content', 'Publication dates exposed', 'na', notSampledLine ? `no articles, news or cases in the sample (not sampled from the sitemap: ${notSampledLine}): nothing to date here` : 'no articles, news or cases in the sample: nothing to date'));
904
+ else if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', (dated >= DATES_OK_MIN || dated >= good.length / 2) && seen >= dated / 2 ? 'ok' : 'warn', dated ? `${dated} of ${good.length} sampled pages carry a date in the markup; on ${seen} of them the same date is also visible on the page` : 'no article dates in the sample: answer engines cannot tell what is current'));
850
905
  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')); }
851
906
 
852
907
  // ---- Off-page and trust ----------------------------------------------------------------
@@ -873,7 +928,22 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
873
928
  const channels = [tel ? 'phone' : '', mail ? 'email' : '', msgr ? 'a messenger' : ''].filter(Boolean);
874
929
  checks.push(row('trust-contact', 'offpage', 'Direct contact details', channels.length >= 2 ? 'ok' : channels.length ? 'warn' : 'bad', channels.length ? channels.join(' and ') : 'no phone, email or messenger is published'));
875
930
  const ownHost = host.replace(/^www\./, '');
876
- const profiles = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAs || []))].filter((h) => h !== ownHost && !h.endsWith(`.${ownHost}`));
931
+ // Ссылка на чат WhatsApp или Viber это способ написать, а не профиль на площадке (cimes.ru, 14.10.2026).
932
+ // Telegram в sameAs бывает каналом (профиль, есть подписчики) и чатом с менеджером (мессенджер, «Send Message»).
933
+ // По адресу не различить: открываем страницу t.me, это бесплатно. cimes.ru (15.10.2026): t.me/CimesWebDevelopment
934
+ // это чат, и «указан 1 профиль: Telegram» был неправдой; у neronov.pro t.me это канал на 322 подписчика.
935
+ const MESSENGER_HOST = /^(wa\.me|api\.whatsapp\.com|whatsapp\.com|chat\.whatsapp\.com|viber\.com|invite\.viber\.com)$/i;
936
+ const tgUrls = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].filter((u) => /^https?:\/\/(www\.)?(t\.me|telegram\.me)\/[^/?#]+/i.test(u)).slice(0, 3);
937
+ const tgKind = {};
938
+ for (const u of tgUrls) {
939
+ const r = await get(u);
940
+ const b = String(r.text || '');
941
+ tgKind[u] = /tgme_page_extra">\s*[\d\s.,KkMmКкМм]+\s*(subscribers|members|подписчик|участник)/i.test(b) ? 'channel' : r.ok && /tgme_action_button|Send Message|Написать/i.test(b) ? 'chat' : 'unknown';
942
+ }
943
+ const tgChatOnly = tgUrls.length > 0 && tgUrls.every((u) => tgKind[u] === 'chat');
944
+ const sameAsHosts = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAs || []))].filter((h) => h !== ownHost && !h.endsWith(`.${ownHost}`));
945
+ const messengersInSchema = sameAsHosts.filter((h) => MESSENGER_HOST.test(h) || (tgChatOnly && /^(t\.me|telegram\.me)$/i.test(h)));
946
+ const profiles = sameAsHosts.filter((h) => !messengersInSchema.includes(h));
877
947
  /*
878
948
  * Для готовой разметки нужен адрес профиля, а не домен: «https://x.com» это не страница компании,
879
949
  * а сам сайт. И список сужается до площадок, где профиль вообще бывает: сайт нередко перечисляет
@@ -884,9 +954,15 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
884
954
  const profileUrls = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].filter((u) => {
885
955
  try { const x = new URL(u); return PROFILE_HOST.test(x.host.replace(/^www\./, '')) && x.pathname.replace(/\/+$/, '').length > 1; } catch { return false; }
886
956
  });
887
- 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'));
957
+ const sameAsAll = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].join(' ');
958
+ const listingsMissing = [...new Set(pagesForTrust.flatMap((p) => p.trust?.listings || []))].filter((u) => { const id = (u.match(/(\d{6,})/) || [])[1]; return id && !sameAsAll.includes(id); }).slice(0, 3);
959
+ const messengerNames = [...new Set(messengersInSchema.map((h) => (/t\.me|telegram/i.test(h) ? 'Telegram' : /whatsapp|wa\.me/i.test(h) ? 'WhatsApp' : /viber/i.test(h) ? 'Viber' : h)))];
960
+ checks.push(row('trust-profiles', 'offpage', 'Profiles the site claims elsewhere', profiles.length >= 2 ? 'ok' : profiles.length ? 'warn' : 'bad', profiles.length ? profileLine(profiles.slice(0, 5), lang, profiles.length) : listingsMissing.length ? `${messengerNames.length ? `only messengers in sameAs (${messengerNames.join(', ')}); ` : ''}a listing shown on the site is missing from the markup: ${listingsMissing.join(', ')}` : messengerNames.length ? `only messengers in sameAs (${messengerNames.join(', ')}): no profile on any platform` : 'no sameAs links: the site claims no profile anywhere else', profiles.length ? '' : 'a free off-site signal: links to your own profiles in the markup'));
961
+ if (!profiles.length && messengerNames.length) checks[checks.length - 1].messengersOnly = messengerNames;
962
+ if (!profiles.length && listingsMissing.length) { checks[checks.length - 1].listings = listingsMissing; checks[checks.length - 1].status = 'warn'; }
888
963
  const about = anyTrust((p) => p.trust?.aboutPage);
889
- 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'));
964
+ // Страницы о компании нет: это «Проблема», а не «Частично» (cimes.ru, 14.10.2026).
965
+ checks.push(row('trust-about', 'offpage', 'A page that says who is behind the site', about ? 'ok' : 'bad', about ? 'linked from the sampled pages' : 'no about or team page is linked'));
890
966
  const policies = [...new Set(pagesForTrust.flatMap((p) => p.trust?.policyPages || []))];
891
967
  checks.push(row('trust-policies', 'offpage', 'Privacy and terms', policies.length >= 2 ? 'ok' : policies.length ? 'warn' : 'bad', policies.length ? policies.join(', ') : 'neither a privacy policy nor terms are linked'));
892
968
 
@@ -904,13 +980,18 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
904
980
  // Если кнопка действия стоит на первом экране почти везде, форма внизу не беда: к ней ведёт кнопка (14.10.2026, neronov.pro).
905
981
  const ctaShare = pagesForTrust.length ? pagesForTrust.filter((p) => p.ctaFirstScreen).length / pagesForTrust.length : 0;
906
982
  checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', md < 50 || ctaShare >= 0.8 ? 'ok' : md < 80 ? 'warn' : 'bad', `median ${md}% of the way down the page, measured on ${depths.length} page(s)`, ctaShare >= 0.8 && md >= 50 ? 'the first screen already has a button that leads to it' : md >= 80 ? 'on a long page almost nobody scrolls that far, least of all on a phone' : ''));
983
+ } else if (pagesForTrust.some((p) => p.formPopup) && withCta.length) {
984
+ // Заявка во всплывающем окне по кнопке: глубины нет, есть кнопка на первом экране.
985
+ const popup = pagesForTrust.filter((p) => p.formPopup).length;
986
+ checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', withCta.length >= pagesForTrust.length * 0.8 ? 'ok' : 'warn', `the form opens in a pop-up from a button on ${popup} of ${pagesForTrust.length} sampled page(s); the button is in the first screen on ${withCta.length}`));
907
987
  } else {
908
988
  // Формы нет, но заявки идут через мессенджеры: это не провал, а другой способ (14.10.2026).
909
989
  const viaMsg = pagesForTrust.filter((p) => p.messengerLinks).length;
910
990
  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'));
911
991
  }
912
- const fields = withForm.map((p) => p.formFields).filter((n) => n > 0);
913
- 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' : '')); }
992
+ // Поля считаем по тем же формам заявки, что и глубину: иначе «форма на одной странице» и «медиана по семи формам» спорили (cimes.ru, 15.10.2026).
993
+ const fields = withForm.filter((p) => p.formDepth !== null && p.formDepth !== undefined).map((p) => p.formFields).filter((n) => n > 0);
994
+ if (fields.length) { const mf = median(fields); checks.push(row('conv-form-fields', 'conversion', 'What the form asks for', mf <= 4 ? 'ok' : mf <= 6 ? 'warn' : 'bad', (fields.length === 1 ? `${mf} field(s) in the only enquiry form found in the sample` : `median ${mf} field(s) before a visitor can send anything`), mf > 4 ? 'every extra field costs replies, and a phone number costs the most' : '')); }
914
995
  const msg = pagesForTrust.filter((p) => p.messengerLinks > 0);
915
996
  checks.push(row('conv-messenger', 'conversion', 'A messenger link', msg.length ? 'ok' : 'warn', msg.length ? `on ${msg.length} of ${pagesForTrust.length} sampled page(s)` : 'none on the sampled pages', msg.length ? '' : 'for an international audience reading on a phone, one messenger link is usually worth more than a form'));
916
997
 
@@ -991,14 +1072,14 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
991
1072
  const ru = lang === 'ru';
992
1073
  const pagesRu = (n) => (n % 10 === 1 && n % 100 !== 11 ? 'страница' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'страницы' : 'страниц');
993
1074
  const S = ru ? {
994
- rows: ['Движок сайта', 'Страниц в карте сайта', 'Язык', 'Чем занимается'],
1075
+ rows: ['Система управления сайтом (CMS)', 'Страниц в карте сайта', 'Язык', 'Чем занимается'],
995
1076
  partial: (n, read, total) => `не меньше ${n}: прочитано ${read} из ${total} файлов карты, остальные не успели`,
996
1077
  site: 'Сам сайт', free: 'Бесплатно, без аккаунта',
997
1078
  siteDetail: (n, viaLinks) => `robots.txt, карты сайта, llms.txt, главная и ${n} ${pagesRu(n)} выборки, прочитанные так же, как их читает любой посетитель${viaLinks ? `; ${viaLinks} из них найдены по ссылкам с главной, потому что в карте сайта их слишком мало` : ''}`,
998
1079
  paid: 'Платный сервис, по желанию', paidDetail: 'только для справки, ни в одну оценку не входит',
999
1080
  limitations: ['Google Search Console и Яндекс Вебмастер: показов, кликов и запросов нет', 'Аналитика: данных о посещениях и поведении нет', 'Сервисы ключевых слов и ссылок: в проверке по публичным сигналам не используются'],
1000
1081
  fix: 'Исправить',
1001
- roadmap: [['Неделя 1', 'Срочное, быстрые победы'], ['Недели 2-4', 'Основа'], ['Месяцы 2-3', 'Рост: поиск и ответы ИИ']],
1082
+ roadmap: [['Неделя 1', 'Три главных приоритета'], ['Недели 2-4', 'Основа'], ['Месяцы 2-3', 'Проверка результата']],
1002
1083
  geo: ['Понятно ли, кто вы', 'Упоминания на других сайтах', 'Отзывы и пресса'],
1003
1084
  conv: ['Форма связи', 'Цена видна', 'Мессенджеры'],
1004
1085
  } : {
@@ -1009,7 +1090,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
1009
1090
  paid: 'Paid tool, optional', paidDetail: 'reported for context only, not counted in any score',
1010
1091
  limitations: ['Google Search Console: no impressions, clicks or query data', 'Analytics: no traffic or behaviour data', 'Keyword and backlink tools: not used in the external tier'],
1011
1092
  fix: 'Fix',
1012
- roadmap: [['Week 1', 'Critical, quick wins'], ['Weeks 2 to 4', 'Foundation'], ['Months 2 to 3', 'Organic growth, AEO and GEO']],
1093
+ roadmap: [['Week 1', 'The three main priorities'], ['Weeks 2 to 4', 'Foundation'], ['Months 2 to 3', 'Check the result']],
1013
1094
  geo: ['Entity clarity', 'Third-party mentions', 'Reviews and PR'],
1014
1095
  conv: ['Contact form', 'Price visible', 'Messengers'],
1015
1096
  };
@@ -1023,7 +1104,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
1023
1104
  scores,
1024
1105
  scoreBasis,
1025
1106
  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}}'] },
1026
- 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.}}' },
1107
+ overview: { rows: [[S.rows[0], hp?.generator || hp?.stack || poweredBy || '{{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.}}' },
1027
1108
  critical: criticalShown,
1028
1109
  checks: localised,
1029
1110
  content: { strengths: ['{{strength}}'], weaknesses: ['{{weakness}}'], gaps: ['{{missing landing page or cluster}}'] },
@@ -1080,7 +1161,8 @@ async function semrushBacklinks(host, log = () => {}, supplied = null) {
1080
1161
  return { ok: true, source: `Semrush Backlink Analytics, ${new Date().toISOString().slice(0, 10)}`, ascore: n('ascore'), total: n('total'), domains: n('domains_num'), follows: n('follows_num'), nofollows: n('nofollows_num') };
1081
1162
  }
1082
1163
 
1083
- function median(a) { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.floor(s.length / 2)] : 0; }
1164
+ // При чётном числе значений середина это среднее двух средних: «1618» при середине 1459 и 1618 было неверно (cimes.ru, 14.10.2026).
1165
+ function median(a) { const s = [...a].sort((x, y) => x - y); if (!s.length) return 0; const m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2); }
1084
1166
 
1085
1167
  /**
1086
1168
  * The six areas of the report. `groups` lists the check groups that feed the score.
package/src/etalon.mjs CHANGED
@@ -17,7 +17,7 @@ export function recorder(realFetch = globalThis.fetch) {
17
17
  const fetchRec = async (input, init = {}) => {
18
18
  const r = await realFetch(input, init);
19
19
  const body = await r.text();
20
- const headers = {}; for (const h of ['content-type', 'location', 'link', 'x-robots-tag']) { const v = r.headers.get(h); if (v) headers[h] = v; }
20
+ const headers = {}; for (const h of ['content-type', 'location', 'link', 'x-robots-tag', 'x-powered-by']) { const v = r.headers.get(h); if (v) headers[h] = v; }
21
21
  store[keyOf(input, init)] = { status: r.status, url: r.url, headers, body };
22
22
  return new Response([101, 204, 205, 304].includes(r.status) ? null : body, { status: r.status, headers });
23
23
  };
package/src/fix.mjs CHANGED
@@ -53,7 +53,7 @@ export const FIX_ACTIONS = {
53
53
  dates: { scope: 'always', action: { ru: 'Вывести дату публикации и обновления в разметку страниц', en: 'Expose published and updated dates in the page markup' } },
54
54
  'trust-entity': { scope: 'always', action: { ru: 'Добавить город и страну в разметку организации', en: 'Add the city and country to the Organization markup' } },
55
55
  'trust-contact': { scope: 'always', action: { ru: 'Опубликовать прямые контакты и вывести их в разметку', en: 'Publish direct contacts and expose them in the markup' } },
56
- 'trust-profiles': { scope: 'always', action: { ru: 'Заявить в разметке профили, которые у клиента действительно есть', en: 'Declare in the markup the profiles you actually have' } },
56
+ 'trust-profiles': { scope: 'always', action: { ru: 'Указать в разметке профили, которые у компании действительно есть', en: 'Declare in the markup the profiles you actually have' } },
57
57
  'trust-about': { scope: 'always', action: { ru: 'Сделать страницу о компании доступной со всех страниц', en: 'Make the about page reachable from every page' } },
58
58
  'trust-policies': { scope: 'always', action: { ru: 'Добавить политику конфиденциальности и условия и сослаться на них из подвала', en: 'Add a privacy policy and terms and link them from the footer' } },
59
59
  'conv-contact-path': { scope: 'always', action: { ru: 'Дать способ связаться с любой страницы', en: 'Give a way to get in touch from any page' } },