@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 +2 -2
- package/src/collect.mjs +106 -24
- package/src/etalon.mjs +1 -1
- package/src/fix.mjs +1 -1
- package/src/i18n.mjs +41 -25
- package/src/index.mjs +1 -0
- package/src/narrative.mjs +80 -32
- package/src/pagekind.mjs +6 -0
- package/src/presentation.mjs +95 -0
- package/src/prompts.mjs +57 -15
- package/src/render.mjs +41 -25
- package/src/rendered.mjs +22 -3
- package/src/review.mjs +15 -7
- package/src/tasks-pdf.mjs +1 -1
- package/src/test-answer-first.mjs +27 -0
- package/src/test-demand.mjs +3 -3
- package/src/test-i18n.mjs +3 -3
- package/src/test-narrative.mjs +1 -1
- package/src/test-overall.mjs +1 -1
- package/src/test-planted.mjs +15 -0
- package/src/test-presentation.mjs +67 -0
- package/src/test-rendered.mjs +11 -0
- package/src/test-shopify-store.mjs +1 -1
- package/src/verify.mjs +30 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "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|&|&) save|get started|start (here|free|now)|try it|check (your|a) site|audit|pricing
|
|
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|&|&) 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
913
|
-
|
|
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', '
|
|
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', '
|
|
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
|
-
|
|
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: '
|
|
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' } },
|