@operstack/audit 0.35.1 → 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.1",
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() ?? '';
@@ -292,7 +296,8 @@ function analysePage(url, html) {
292
296
  // A honeypot is invisible to a person, so counting it as a field the visitor must fill is wrong.
293
297
  const visibleToVisitor = (el) => {
294
298
  if (el.getAttribute('tabindex') === '-1') return false;
295
- 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;
296
301
  for (let n = el.parentNode; n; n = n.parentNode) {
297
302
  if (typeof n.getAttribute !== 'function') break;
298
303
  if (n.getAttribute('aria-hidden') === 'true') return false;
@@ -301,7 +306,7 @@ function analysePage(url, html) {
301
306
  }
302
307
  return true;
303
308
  };
304
- 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;
305
310
  const formAt = firstForm ? bodyInner.indexOf(firstForm.outerHTML.slice(0, 120)) : -1;
306
311
  const formDepth = firstForm && formAt >= 0 && bodyInner.length ? Math.round((formAt / bodyInner.length) * 100) : null;
307
312
  /*
@@ -344,6 +349,12 @@ function analysePage(url, html) {
344
349
  email: /"email"\s*:/.test(ld) || mailLinks > 0,
345
350
  sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
346
351
  sameAsUrls: [...new Set(sameAs)],
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
+ ])],
347
358
  // Только ссылки внутри своего сайта: «yandex.ru/support/.../about» засчитывался cimes.ru как страница о компании (14.10.2026).
348
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); }),
349
360
  policyPages: [...new Set(hrefsLower.flatMap(policyKinds))],
@@ -352,13 +363,22 @@ function analysePage(url, html) {
352
363
  // Answer-first family, the same measures the free AI visibility check uses, so the two agree.
353
364
  const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
354
365
  // Без H1 первый абзац берём из тела страницы: иначе у главной без H1 «текста нет» (14.10.2026, medeq-mc.ru).
355
- 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)
356
375
  || ([...bodyHtml.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => plain(m[1])).find((t) => (t.match(/[\p{L}]+/gu) || []).length >= 12) || '');
357
376
  // Фраза, где сайт сам говорит, чем занимается: для строки «Чем занимается» при пустом описании (14.10.2026).
358
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);
359
378
  const h2List = [...bodyHtml.matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi)].map((m) => plain(m[1]).trim()).filter(Boolean).slice(0, 10);
360
379
  const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
361
- 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);
362
382
  const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
363
383
  const h3Count = (bodyHtml.match(/<h3[\s>]/gi) || []).length;
364
384
  const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
@@ -733,6 +753,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
733
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 };
734
754
  const good = sample.filter((p) => p.title !== undefined);
735
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) : ''; })();
736
757
 
737
758
  if (hp) {
738
759
  // Ноль в этих трёх строках означает «элемента нет», а не «маленькое значение». Строка
@@ -795,6 +816,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
795
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'));
796
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)`));
797
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)`));
798
822
  checks.push(row('hreflang', 'technical', 'hreflang', hp.hreflang ? 'ok' : 'na', hp.hreflang ? `${hp.hreflang} alternate(s)` : 'none (single language)'));
799
823
  }
800
824
  const imgs = good.reduce((a, p) => a + p.images, 0); const noAlt = good.reduce((a, p) => a + p.imagesNoAlt, 0);
@@ -867,7 +891,17 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
867
891
  // на каждую страницу, а на странице услуги даты может не быть вовсе. Считаем обе и пишем обе.
868
892
  // Видимая дата засчитывается, только если совпадает с датой в разметке: дата лицензии или чужой новости в боковом блоке это не дата страницы.
869
893
  const seen = good.filter((p) => p.dates.visibleMatch).length;
870
- 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'));
871
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')); }
872
906
 
873
907
  // ---- Off-page and trust ----------------------------------------------------------------
@@ -895,7 +929,21 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
895
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'));
896
930
  const ownHost = host.replace(/^www\./, '');
897
931
  // Ссылка на чат WhatsApp или Viber это способ написать, а не профиль на площадке (cimes.ru, 14.10.2026).
898
- const profiles = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAs || []))].filter((h) => h !== ownHost && !h.endsWith(`.${ownHost}`) && !/^(wa\.me|api\.whatsapp\.com|whatsapp\.com|chat\.whatsapp\.com|viber\.com|invite\.viber\.com)$/i.test(h));
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));
899
947
  /*
900
948
  * Для готовой разметки нужен адрес профиля, а не домен: «https://x.com» это не страница компании,
901
949
  * а сам сайт. И список сужается до площадок, где профиль вообще бывает: сайт нередко перечисляет
@@ -906,9 +954,15 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
906
954
  const profileUrls = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].filter((u) => {
907
955
  try { const x = new URL(u); return PROFILE_HOST.test(x.host.replace(/^www\./, '')) && x.pathname.replace(/\/+$/, '').length > 1; } catch { return false; }
908
956
  });
909
- 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'; }
910
963
  const about = anyTrust((p) => p.trust?.aboutPage);
911
- 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'));
912
966
  const policies = [...new Set(pagesForTrust.flatMap((p) => p.trust?.policyPages || []))];
913
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'));
914
968
 
@@ -935,8 +989,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
935
989
  const viaMsg = pagesForTrust.filter((p) => p.messengerLinks).length;
936
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'));
937
991
  }
938
- const fields = withForm.map((p) => p.formFields).filter((n) => n > 0);
939
- 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' : '')); }
940
995
  const msg = pagesForTrust.filter((p) => p.messengerLinks > 0);
941
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'));
942
997
 
@@ -1017,14 +1072,14 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
1017
1072
  const ru = lang === 'ru';
1018
1073
  const pagesRu = (n) => (n % 10 === 1 && n % 100 !== 11 ? 'страница' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'страницы' : 'страниц');
1019
1074
  const S = ru ? {
1020
- rows: ['Движок сайта', 'Страниц в карте сайта', 'Язык', 'Чем занимается'],
1075
+ rows: ['Система управления сайтом (CMS)', 'Страниц в карте сайта', 'Язык', 'Чем занимается'],
1021
1076
  partial: (n, read, total) => `не меньше ${n}: прочитано ${read} из ${total} файлов карты, остальные не успели`,
1022
1077
  site: 'Сам сайт', free: 'Бесплатно, без аккаунта',
1023
1078
  siteDetail: (n, viaLinks) => `robots.txt, карты сайта, llms.txt, главная и ${n} ${pagesRu(n)} выборки, прочитанные так же, как их читает любой посетитель${viaLinks ? `; ${viaLinks} из них найдены по ссылкам с главной, потому что в карте сайта их слишком мало` : ''}`,
1024
1079
  paid: 'Платный сервис, по желанию', paidDetail: 'только для справки, ни в одну оценку не входит',
1025
1080
  limitations: ['Google Search Console и Яндекс Вебмастер: показов, кликов и запросов нет', 'Аналитика: данных о посещениях и поведении нет', 'Сервисы ключевых слов и ссылок: в проверке по публичным сигналам не используются'],
1026
1081
  fix: 'Исправить',
1027
- roadmap: [['Неделя 1', 'Срочное, быстрые победы'], ['Недели 2-4', 'Основа'], ['Месяцы 2-3', 'Рост: поиск и ответы ИИ']],
1082
+ roadmap: [['Неделя 1', 'Три главных приоритета'], ['Недели 2-4', 'Основа'], ['Месяцы 2-3', 'Проверка результата']],
1028
1083
  geo: ['Понятно ли, кто вы', 'Упоминания на других сайтах', 'Отзывы и пресса'],
1029
1084
  conv: ['Форма связи', 'Цена видна', 'Мессенджеры'],
1030
1085
  } : {
@@ -1035,7 +1090,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
1035
1090
  paid: 'Paid tool, optional', paidDetail: 'reported for context only, not counted in any score',
1036
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'],
1037
1092
  fix: 'Fix',
1038
- 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']],
1039
1094
  geo: ['Entity clarity', 'Third-party mentions', 'Reviews and PR'],
1040
1095
  conv: ['Contact form', 'Price visible', 'Messengers'],
1041
1096
  };
@@ -1049,7 +1104,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
1049
1104
  scores,
1050
1105
  scoreBasis,
1051
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}}'] },
1052
- 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.}}' },
1053
1108
  critical: criticalShown,
1054
1109
  checks: localised,
1055
1110
  content: { strengths: ['{{strength}}'], weaknesses: ['{{weakness}}'], gaps: ['{{missing landing page or cluster}}'] },
@@ -1106,7 +1161,8 @@ async function semrushBacklinks(host, log = () => {}, supplied = null) {
1106
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') };
1107
1162
  }
1108
1163
 
1109
- 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); }
1110
1166
 
1111
1167
  /**
1112
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' } },
package/src/i18n.mjs CHANGED
@@ -21,9 +21,22 @@ const plural = (n, one, few, many) => {
21
21
  };
22
22
  /** Проценты по-русски склоняются: 31 процент, 32 процента, 35 процентов. */
23
23
  const pct = (n) => plural(Number(n), 'процент', 'процента', 'процентов');
24
+ // «1: t.me» владелец не читает: называем площадку по-человечески (cimes.ru, 14.10.2026).
25
+ const PROFILE_NAMES = { 't.me': 'Telegram', 'telegram.me': 'Telegram', 'vk.com': 'ВКонтакте', 'ok.ru': 'Одноклассники', 'dzen.ru': 'Дзен', 'rutube.ru': 'Rutube', 'youtube.com': 'YouTube', 'instagram.com': 'Instagram', 'facebook.com': 'Facebook', 'linkedin.com': 'LinkedIn', 'x.com': 'X', 'twitter.com': 'X', 'tiktok.com': 'TikTok', 'pinterest.com': 'Pinterest', 'github.com': 'GitHub', 'behance.net': 'Behance', 'prodoctorov.ru': 'ПроДокторов', '2gis.ru': '2ГИС', 'zoon.ru': 'Zoon', 'yandex.ru': 'Яндекс', 'avito.ru': 'Авито', 'profi.ru': 'Профи' };
26
+ const profileName = (h) => PROFILE_NAMES[String(h).trim().replace(/^www\./, '')] || String(h).trim();
27
+ /** Строка профилей для отчёта: «указан 1 профиль: Telegram», «2 profiles: Telegram, VK». Голые «1: t.me» владелец не читает. */
28
+ export function profileLine(hosts, lang = 'en', total = hosts.length) {
29
+ const names = [...new Set(hosts.map(profileName))].join(', ');
30
+ if (lang === 'ru') return `${total === 1 ? 'указан' : 'указано'} ${plural(total, 'профиль', 'профиля', 'профилей')}: ${names}`;
31
+ return `${total} profile${total === 1 ? '' : 's'}: ${names}`;
32
+ }
24
33
  const pages = (n) => plural(Number(n), 'страница', 'страницы', 'страниц');
25
34
  const pagesOf = (a, b) => `${a} из ${pages(b)}`;
26
35
  /** «из 21 страницы», «из 20 страниц»: после «из» родительный падеж. */
36
+ // «1 из 20 страниц указывает»: глагол по числу страниц, где это есть (cimes.ru, 14.10.2026).
37
+ const vb = (n, one, many) => (Number(n) % 10 === 1 && Number(n) % 100 !== 11 ? one : many);
38
+ // «есть на 7 страницах», «на 1 странице»: предложный падеж после «на» (cimes.ru, 14.10.2026).
39
+ const pagesLoc = (n) => (Number(n) % 10 === 1 && Number(n) % 100 !== 11 ? 'странице' : 'страницах');
27
40
  const pagesGen = (n) => (Number(n) % 10 === 1 && Number(n) % 100 !== 11 ? 'страницы' : 'страниц');
28
41
 
29
42
  /** Перевод «сколько проверок пройдено» из подписи под оценкой. */
@@ -35,7 +48,7 @@ export function localiseBasisNote(note) {
35
48
  }
36
49
  const [, ok, total, warn, bad] = m;
37
50
  const parts = [`${ok} из ${plural(Number(total), 'проверки', 'проверок', 'проверок')} пройдено`];
38
- if (warn) parts.push(plural(Number(warn), 'спорная', 'спорные', 'спорных'));
51
+ if (warn) parts.push(`${Number(warn)} частично`);
39
52
  if (bad) parts.push(plural(Number(bad), 'провалена', 'провалены', 'провалено'));
40
53
  return parts.join(', ');
41
54
  }
@@ -99,15 +112,15 @@ const RU = {
99
112
  canonical: { label: 'Канонический адрес', value: (v) => (/missing|none/i.test(v) ? 'не указан' : /of \d+ sampled/.test(v) ? `указан на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки` : 'указан'), comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
100
113
  // Добавлено 17.09.2026 вместе с новыми проверками. Русский отчёт платный, английские строки в нём
101
114
  // это такой же брак, как русский отчёт англоязычному покупателю.
102
- 'title-all': { label: 'Заголовок вкладки на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
103
- 'description-all': { label: 'Описание на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
104
- 'schema-all': { label: 'Разметка на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
105
- 'og-title': { label: 'og:title на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (c ? 'без него при пересылке ссылки видно голый адрес или чужой заголовок' : '') },
106
- 'og-image': { label: 'og:image на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (c ? 'без og:image мессенджеры и соцсети показывают ссылку без картинки' : '') },
115
+ 'title-all': { label: 'Заголовок вкладки на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
116
+ 'description-all': { label: 'Описание на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
117
+ 'schema-all': { label: 'Разметка на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
118
+ 'og-title': { label: 'og:title на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (c ? 'без него при пересылке ссылки видно голый адрес или чужой заголовок' : '') },
119
+ 'og-image': { label: 'og:image на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (c ? 'без og:image мессенджеры и соцсети показывают ссылку без картинки' : '') },
107
120
  'twitter-card': { label: 'Карточка Twitter', value: (v) => (/no twitter/.test(v) ? 'не задана' : v.replace('twitter:card', 'тип')), comment: (c) => (c ? 'на неё опираются X и часть читалок, когда Open Graph неполный' : '') },
108
- 'breadcrumbs-schema': { label: 'Разметка хлебных крошек', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${pages(nums(v)[0])} выборки`), comment: (c) => (c ? 'крошки говорят системе, где страница стоит, и показываются в ответе рядом со ссылкой' : '') },
109
- 'howto-schema': { label: 'Разметка пошаговых инструкций', value: (v) => (/no step-by-step/.test(v) ? 'пошаговых страниц в выборке нет' : `есть на ${pages(nums(v)[0])} выборки`), comment: (c) => (c ? 'ставить только там, где страница правда ведёт по шагам: разметить ею что-то другое значит соврать системе' : '') },
110
- 'product-schema': { label: 'Разметка товара', value: (v) => (/no product/.test(v) ? 'страниц товара в выборке нет' : `есть на ${pages(nums(v)[0])} выборки`), comment: (c) => (c ? 'только для страниц, которые правда продают названную вещь с ценой' : '') },
121
+ 'breadcrumbs-schema': { label: 'Разметка хлебных крошек', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`), comment: (c) => (c ? 'крошки говорят системе, где страница стоит, и показываются в ответе рядом со ссылкой' : '') },
122
+ 'howto-schema': { label: 'Разметка пошаговых инструкций', value: (v) => (/no step-by-step/.test(v) ? 'пошаговых страниц в выборке нет' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`), comment: (c) => (c ? 'ставить только там, где страница правда ведёт по шагам: разметить ею что-то другое значит соврать системе' : '') },
123
+ 'product-schema': { label: 'Разметка товара', value: (v) => (/no product/.test(v) ? 'страниц товара в выборке нет' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`), comment: (c) => (c ? 'только для страниц, которые правда продают названную вещь с ценой' : '') },
111
124
  'redirect-chain': { label: 'Переадресации до главной', value: (v) => (/without a redirect/.test(v) ? 'адрес отвечает сразу, без переадресаций' : `${plural(Number(nums(v)[0]), 'переадресация', 'переадресации', 'переадресаций')} до ответа`), comment: (c) => (c ? 'направьте первый адрес сразу на конечный: каждый прыжок стоит времени и веса ссылки' : '') },
112
125
  favicon: { label: 'Значок сайта', value: (v) => (/declared/.test(v) ? 'объявлен в шапке страницы' : /answers 200/.test(v) ? '/favicon.ico отвечает' : 'не объявлен, и /favicon.ico не отвечает'), comment: (c) => (c ? 'вкладка, закладка и карточка при пересылке ссылки останутся с пустым квадратом' : '') },
113
126
  'engine-chatgpt': { label: 'ChatGPT может читать сайт', value: (v) => (/blocked/.test(v) ? `закрыт в robots.txt: ${v.replace(/^blocked in robots.txt:\s*/, '')}` : 'открыт'), comment: (c) => (c ? 'ChatGPT не заберёт страницу, когда она понадобится для ответа, и не процитирует вас даже там, где вы лучший ответ' : '') },
@@ -119,26 +132,26 @@ const RU = {
119
132
  viewport: { label: 'Мобильный viewport', value: (v) => (/missing/.test(v) ? 'не задан' : v), comment: (c) => (c ? 'запрещает масштабирование: плохо для доступности и мобильного поиска' : '') },
120
133
  og: { label: 'Карточки для соцсетей', value: (v) => (/none/.test(v) ? 'нет' : v), comment: (c) => (/shortcode/.test(c) ? 'в описании служебный код вместо текста' : '') },
121
134
  schema: { label: 'Разметка на главной', value: (v) => (/no JSON-LD/.test(v) ? 'разметки нет' : /invalid/.test(v) ? 'разметка есть, но с ошибкой' : v) },
122
- 'faq-schema': { label: 'Разметка вопросов FAQPage', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${pages(nums(v)[0])} выборки`) },
135
+ 'faq-schema': { label: 'Разметка вопросов FAQPage', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`) },
123
136
  'org-schema': { label: 'Разметка организации', value: (v) => (/not found/.test(v) ? 'не найдена: ответным системам не к чему привязать сайт' : 'есть') },
124
- resources: { label: 'Вес главной страницы', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'файл скриптов', 'файла скриптов', 'файлов скриптов')}, ${plural(Number(n[1]), 'стиль', 'стиля', 'стилей')}, ${plural(Number(n[2]), 'изображение', 'изображения', 'изображений')}`; } },
137
+ resources: { label: 'Файлы главной страницы', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'файл скриптов', 'файла скриптов', 'файлов скриптов')}, ${plural(Number(n[1]), 'стиль', 'стиля', 'стилей')}, ${plural(Number(n[2]), 'изображение', 'изображения', 'изображений')}`; } },
125
138
  hreflang: { label: 'hreflang', value: (v) => (/none/.test(v) ? 'нет, сайт одноязычный' : `${plural(Number(nums(v)[0]), 'альтернатива', 'альтернативы', 'альтернатив')}`) },
126
- cms: { label: 'Движок сайта', value: (v) => v.replace(' (detected from the page code)', ' (определён по коду страницы)') },
139
+ cms: { label: 'Система управления сайтом (CMS)', value: (v) => v.replace(' (detected from the page code)', ' (определён по коду страницы)').replace(' (from the X-Powered-By server header)', ' (по заголовку сервера X-Powered-By)') },
127
140
  xmlrpc: { label: 'xmlrpc.php', value: (v) => `отвечает ${nums(v)[0]}`, comment: () => 'открытая точка для атак, пользы для поиска никакой' },
128
141
  alt: { label: 'Подписи к картинкам', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} картинок имеют подпись, это ${pct(n[2])}, на ${n[3]} ${Number(n[3]) % 10 === 1 && Number(n[3]) % 100 !== 11 ? 'странице' : 'страницах'} выборки`; } },
129
142
  'h1-sample': { label: 'Структура заголовков', value: (v) => (/one H1 (?:per page|on every sampled page)/.test(v) ? 'по одному H1 на каждой странице выборки' : v.split('; ').map((part) => { const n = nums(part); return /no H1/.test(part) ? `H1 нет на ${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки` : `больше одного H1 на ${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки`; }).join('; ')) },
130
143
  'dup-titles': { label: 'Повторы заголовков', value: (v) => (/its own title/.test(v) ? 'у каждой проверенной страницы свой заголовок' : `${plural(Number(nums(v)[0]), 'заголовок повторяется', 'заголовка повторяются', 'заголовков повторяются')} на разных страницах`) },
131
- thin: { label: 'Тонкие страницы', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки короче 300 слов`) },
144
+ thin: { label: 'Объём текста', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки короче 300 слов`) },
132
145
  utility: { label: 'Служебные страницы в карте сайта', value: (v) => (/^1 URL: /.test(v) ? `1 адрес: ${v.slice(7)}` : `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')}, например ${v.split('such as ')[1] || ''}`), comment: (c) => { const map = { 'author pages': 'страницы авторов', 'tag pages': 'страницы тегов', 'cart and checkout pages': 'корзина и оформление заказа', 'wishlist pages': 'избранное', 'account pages': 'личный кабинет', feeds: 'ленты RSS', 'utility pages': 'служебные страницы' }; const k = String(c).replace(/ add noise to the index$/, '').split(', ').map((x) => map[x] || x); return `${k.join(', ')}: в индексе не нужны и засоряют его`; } },
133
146
  'answer-first': {
134
147
  label: 'Ответ в первом абзаце',
135
- value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки начинаются с 20-90 слов, где есть цифра, сразу после H1`; },
148
+ value: (v) => { const n = nums(v); const miss = Number(n[1]) - Number(n[0]); return miss > 0 ? `прямого ответа с цифрой в первом абзаце (20-90 слов сразу после H1) нет на ${miss} из ${n[1]} ${pagesGen(n[1])} выборки` : `прямой ответ с цифрой в первом абзаце есть на всех ${n[1]} страницах выборки`; },
136
149
  comment: (c) => (c ? 'именно этот абзац ответная система забирает в цитату' : ''),
137
150
  },
138
- sections: { label: 'Разбивка на разделы', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки имеют три и более подзаголовка H2`; }, comment: (c) => (c ? 'системы цитируют разделы, а не сплошной текст' : '') },
151
+ sections: { label: 'Разбивка на разделы', value: (v) => { const n = nums(v); const few = Number(n[1]) - Number(n[0]); return few > 0 ? `меньше трёх подзаголовков H2 на ${few} из ${n[1]} ${pagesGen(n[1])} выборки` : `три и более подзаголовка H2 на всех ${n[1]} страницах выборки`; }, comment: (c) => (c ? 'системы цитируют разделы, а не сплошной текст' : '') },
139
152
  tables: { label: 'Таблицы в тексте', value: (v) => (/no table/.test(v) ? 'на проверенных страницах таблиц нет' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки используют таблицу`), comment: (c) => (c ? 'таблица это второй по частоте формат цитирования после первого абзаца' : '') },
140
- sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); if (n.length < 2) return /no sampled page carries a figure/.test(v) ? 'на страницах выборки нет цифр, которым нужен источник' : 'не измеряли'; return `${n[0]} из ${pages(n[1])} выборки, где есть цифры, называют, откуда взята цифра, словами или ссылкой рядом с ней`; }, comment: (c) => (c ? 'цифра без источника это первое, что система выбрасывает' : '') },
141
- dates: { label: 'Открытые даты публикации', value: (v) => (/no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : /in the markup/.test(v) ? `${nums(v)[0]} из ${pages(nums(v)[1])} выборки указывают дату в разметке; та же дата видна на самой странице на ${nums(v)[2]}` : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки показывают дату`) },
153
+ sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); if (n.length < 2) return /no sampled page carries a figure/.test(v) ? 'на страницах выборки нет цифр, которым нужен источник' : 'не измеряли'; return `${n[0]} из ${pages(n[1])} выборки, где есть цифры статистики (доли, количества, сравнения), называют, откуда взята цифра, словами или ссылкой рядом с ней`; }, comment: (c) => (c ? 'цифра без источника это первое, что система выбрасывает' : '') },
154
+ dates: { label: 'Открытые даты публикации', value: (v) => (/not sampled from the sitemap/.test(v) ? (() => { const n = (k) => Number((v.match(new RegExp(`${k} (\\d+)`)) || [])[1] || 0); const parts = [n('blog') && `статьи блога (${n('blog')})`, n('news') && `новости (${n('news')})`, n('cases') && `работы из портфолио (${n('cases')})`].filter(Boolean); const list = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} и ${parts[parts.length - 1]}` : parts[0]; return `${list} в выборку не вошли, на страницах выборки датировать нечего`; })() : /nothing to date/.test(v) ? 'статей, новостей и кейсов в выборке нет: датировать нечего' : /precede published/.test(v) ? `дата изменения стоит раньше даты публикации на ${nums(v)[0]} ${plural(Number(nums(v)[0]), 'статье', 'статьях', 'статьях').replace(/^\d+ /, '')}` : /no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : /in the markup/.test(v) ? `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки ${vb(nums(v)[0], 'указывает', 'указывают')} дату в разметке${nums(v)[2] === undefined ? '' : Number(nums(v)[0]) === 1 ? (Number(nums(v)[2]) ? ', и на этой странице та же дата видна в тексте' : ', но в тексте этой страницы даты не видно') : `; на ${nums(v)[2]} из них та же дата видна и в тексте`}` : `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки ${vb(nums(v)[0], 'показывает', 'показывают')} дату`) },
142
155
  'llms-health': { label: 'Страницы из llms.txt', value: (v) => (/^all /.test(v) ? `все ${nums(v)[0]} проверенных страниц отвечают и открыты для индексации` : v.replace(/(\d+) dead/, '$1 мёртвых').replace(/(\d+) redirecting/, '$1 с переадресацией').replace(/(\d+) closed to indexing/, '$1 закрытых от индексации').replace(/ of (\d+) checked: /, ' из $1 проверенных: ')), comment: (c) => (c ? 'ИИ читает этот файл вместо обхода сайта: каждая мёртвая или закрытая запись это страница, которую он процитирует неверно или не процитирует вовсе' : '') },
143
156
  'agent-card': { label: 'Карточка агента (/.well-known/agent.json)', value: (v) => (/valid JSON/.test(v) ? 'есть, и это правильный JSON' : /not JSON/.test(v) ? 'есть, но это не JSON' : 'нет'), comment: (c) => (c ? 'ранний и необязательный стандарт: файл описывает программного ИИ-агента, если он у компании есть; без своего агента он не нужен' : '') },
144
157
  'api-catalog': { label: 'Каталог API (RFC 9727)', value: (v) => (/^present/.test(v) ? 'есть' : 'нет'), comment: (c) => (c ? 'необязательно, и нужно только если у бизнеса действительно есть API, который агент мог бы вызвать' : '') },
@@ -148,15 +161,15 @@ const RU = {
148
161
  'trust-contact': {
149
162
  label: 'Прямые контакты',
150
163
  value: (v) => (/no phone, email or messenger/.test(v) ? 'ни телефона, ни почты, ни мессенджера не опубликовано'
151
- : v.replace(/\bphone\b/g, 'телефон').replace(/\bemail\b/g, 'почта').replace(/a messenger/g, 'мессенджер').replace(/ and /g, ' и ')),
164
+ : v.replace(/\bphone\b/g, 'телефон').replace(/\bemail\b/g, 'почта').replace(/a messenger/g, 'мессенджер').replace(/, | and /g, ', ').replace(/, ([^,]+)$/, ' и $1')),
152
165
  },
153
- 'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' : `${nums(v)[0]}: ${v.split(': ').slice(1).join(': ')}`), comment: (c) => (c ? 'бесплатный внешний сигнал: ссылки на свои профили в разметке' : '') },
166
+ 'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/listing shown on the site is missing/.test(v) ? (() => { const urls = v.split('markup: ')[1] || ''; const where = /yandex/.test(urls) ? 'в Яндекс Картах' : /2gis/.test(urls) ? 'в 2ГИС' : 'на картах'; const m = (v.match(/only messengers in sameAs \(([^)]*)\)/) || [])[1]; return `${m ? `в разметке (sameAs) указаны только мессенджеры (${m}); ` : ''}ваша карточка ${where} на сайте показана, но в разметке не указана: ${urls}`; })() : /only messengers in sameAs/.test(v) ? `в разметке указаны только мессенджеры (${(v.match(/\(([^)]*)\)/) || ['', ''])[1]}), профилей на площадках нет` : /no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' : /^\d+ profiles?:/.test(v) ? (() => { const n = Number(nums(v)[0]); return `${n === 1 ? 'указан' : 'указано'} ${plural(n, 'профиль', 'профиля', 'профилей')}: ${v.split(': ').slice(1).join(': ')}`; })() : /^\d+:/.test(v) ? (() => { const n = Number(nums(v)[0]); const names = v.split(': ').slice(1).join(': ').split(/,\s*/).map(profileName).join(', '); return `${n === 1 ? 'указан' : 'указано'} ${plural(n, 'профиль', 'профиля', 'профилей')}: ${names}`; })() : v), comment: (c) => (c ? 'ссылки на настоящие профили компании в разметке помогают поиску и ИИ убедиться, что компания существует, и это ничего не стоит' : '') },
154
167
  'trust-about': { label: 'Страница о компании', value: (v) => (/no about/.test(v) ? 'страницы о компании нет или на неё не ведут ссылки' : 'на неё ведут ссылки с проверенных страниц') },
155
168
  'trust-policies': { label: 'Политика и условия', value: (v) => (/neither/.test(v) ? 'ни политики конфиденциальности, ни условий на сайте нет' : `есть: ${v.replace(/privacy/g, 'политика').replace(/terms/g, 'условия').replace(/legal/g, 'правовая страница').replace(/cookie/g, 'куки').replace(/disclaimer/g, 'оговорка').replace(/consent/g, 'согласие на обработку данных')}`) },
156
- 'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки дают форму, контактную ссылку или страницу контактов`; } },
157
- 'conv-cta': { label: 'Призыв к действию в первом экране', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки ставят его в первых ${n[2]} процентах страницы`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
158
- 'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/pop-up/.test(v) ? `форма открывается во всплывающем окне по кнопке на ${nums(v)[0]} из ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'страницы' : 'страниц'} выборки; кнопка на первом экране на ${nums(v)[2]}` : /messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки)` : /no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${pct(nums(v)[0])} вниз по странице, измерено на ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'странице' : 'страницах'}`), comment: (c) => (/button that leads/.test(c) ? 'на первом экране уже есть кнопка, которая ведёт к форме' : c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
159
- 'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`, comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
169
+ 'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'даёт', 'дают')} форму, контактную ссылку или страницу контактов`; } },
170
+ 'conv-cta': { label: 'Призыв к действию на первом экране', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'ставит', 'ставят')} его в первых ${n[2]} процентах страницы`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
171
+ 'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/pop-up/.test(v) ? `форма открывается во всплывающем окне по кнопке на ${nums(v)[0]} из ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'страницы' : 'страниц'} выборки; кнопка на первом экране на ${nums(v)[2]}` : /messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки)` : /no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : (Number(nums(v)[1]) === 1 ? `форма заявки есть на одной странице выборки, на ${pct(nums(v)[0])} вниз по коду страницы` : `медиана ${pct(nums(v)[0])} вниз по коду страницы, измерено на ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'странице' : 'страницах'}`)), comment: (c) => (/button that leads/.test(c) ? 'на первом экране уже есть кнопка, которая ведёт к форме' : c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
172
+ 'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => (/only (enquiry )?form/.test(v) ? `в единственной форме заявки в выборке ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')}` : `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`), comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
160
173
  'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
161
174
  };
162
175
 
package/src/index.mjs CHANGED
@@ -15,3 +15,4 @@ export { consistencyCheck } from './consistency.mjs';
15
15
  export { detectNiche, vocabFor } from './niche.mjs';
16
16
  export { renderTasksHtml, renderTasksPdf } from './tasks-pdf.mjs';
17
17
  export { printFitted, pdfPageCount } from './render.mjs';
18
+ export { presentationCheck, STATUS_OK_SHARE, STATUS_OK_MIN } from './presentation.mjs';