@operstack/audit 0.33.4 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.33.4",
3
+ "version": "0.34.0",
4
4
  "description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,7 +17,12 @@
17
17
  "./narrative": "./src/narrative.mjs",
18
18
  "./prompts": "./src/prompts.mjs",
19
19
  "./rendered": "./src/rendered.mjs",
20
- "./demand": "./src/demand.mjs"
20
+ "./demand": "./src/demand.mjs",
21
+ "./tasks-pdf": "./src/tasks-pdf.mjs",
22
+ "./verify": "./src/verify.mjs",
23
+ "./review": "./src/review.mjs",
24
+ "./consistency": "./src/consistency.mjs",
25
+ "./niche": "./src/niche.mjs"
21
26
  },
22
27
  "files": [
23
28
  "bin",
@@ -29,7 +34,7 @@
29
34
  "LICENSE"
30
35
  ],
31
36
  "scripts": {
32
- "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs && node src/test-report-wording.mjs && node src/test-shopify-store.mjs",
37
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs && node src/test-report-wording.mjs && node src/test-shopify-store.mjs && node src/test-etalon.mjs",
33
38
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
34
39
  "verify:live": "node src/verify-live.mjs"
35
40
  },
package/src/collect.mjs CHANGED
@@ -187,9 +187,19 @@ function analysePage(url, html) {
187
187
  const attr = (sel, a) => root.querySelector(sel)?.getAttribute(a) ?? '';
188
188
  const title = root.querySelector('title')?.text.trim() ?? '';
189
189
  const description = attr('meta[name="description"]', 'content').trim();
190
- const h1s = root.querySelectorAll('h1').map((h) => h.text.trim()).filter(Boolean);
190
+ // H1 из шаблонов Vue и подобных (v-if, :title, {{ }} или внутри <template>) человек не видит: не считаем (14.10.2026, brainmd.com).
191
+ const inTemplate = (el) => { for (let n = el.parentNode; n; n = n.parentNode) { const tag = String(n.rawTagName || n.tagName || '').toLowerCase(); if (tag === 'template' || tag === 'script') return true; } return false; };
192
+ const framework = (el) => Object.keys(el.attributes || {}).some((k) => /^(v-|:|@|x-)/.test(k)) || /\{\{|\}\}/.test(el.text);
193
+ const h1s = root.querySelectorAll('h1').filter((h) => !inTemplate(h) && !framework(h)).map((h) => h.text.trim()).filter(Boolean);
191
194
  const imgs = root.querySelectorAll('img');
192
- const imgsNoAlt = imgs.filter((i) => !(i.getAttribute('alt') || '').trim()).length;
195
+ // Значки оформления (svg с пустым alt) и счётчики-пиксели не картинки содержания: пустой alt у них правильный.
196
+ // Фото, выведенные блоком с role="img" и aria-label (Tilda), считаем подписанными (14.10.2026).
197
+ const decorative = (i) => (/\.svg(\?|$)/i.test(i.getAttribute('src') || i.getAttribute('data-original') || '') && !(i.getAttribute('alt') || '').trim()) || /mc\.yandex|facebook\.com\/tr|pixel/i.test(i.getAttribute('src') || '');
198
+ const roleImgs = root.querySelectorAll('[role="img"]').filter((e) => String(e.rawTagName || '').toLowerCase() !== 'img' && String(e.rawTagName || '').toLowerCase() !== 'svg');
199
+ const contentImgs = imgs.filter((i) => !decorative(i));
200
+ const imgsNoAlt = contentImgs.filter((i) => !(i.getAttribute('alt') || '').trim()).length + roleImgs.filter((e) => !(e.getAttribute('aria-label') || '').trim()).length;
201
+ // Атрибута нет вовсе или он есть, но пустой: это разные задачи (14.10.2026, medeq-mc.ru: alt стоял у всех 1228 картинок, пустой у 1163).
202
+ const imgsAltMissing = imgs.filter((i) => i.getAttribute('alt') === null || i.getAttribute('alt') === undefined).length;
193
203
  const canonical = attr('link[rel="canonical"]', 'href');
194
204
  const robots = attr('meta[name="robots"]', 'content');
195
205
  const viewport = attr('meta[name="viewport"]', 'content');
@@ -236,6 +246,14 @@ function analysePage(url, html) {
236
246
  // brainmd.com читался как «профилей нет», и это шло в приоритеты отчёта (12.10.2026).
237
247
  const ld = root.querySelectorAll('script[type="application/ld+json"]').map((x) => x.text).join('\n').replace(/\\\//g, '/');
238
248
  const dates = { published: attr('meta[property="article:published_time"]', 'content') || (ld.match(/"datePublished"\s*:\s*"([^"]+)"/) || [])[1] || '', modified: attr('meta[property="article:modified_time"]', 'content') || (ld.match(/"dateModified"\s*:\s*"([^"]+)"/) || [])[1] || '' };
249
+ { const top = String(text || '').slice(0, Math.max(600, Math.round(String(text || '').length * 0.3)));
250
+ const isos = [dates.published, dates.modified].filter(Boolean).map((x) => String(x).slice(0, 10));
251
+ const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
252
+ const MONTHS_RU = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
253
+ const tx = String(text || '');
254
+ // Совпадение с датой публикации или с датой изменения: «Обновлено: 7 октября 2026» тоже честная видимая дата.
255
+ dates.visibleMatch = isos.some((iso) => { const [Y, M, D] = iso.split('-'); if (!Y || !D) return false; const mi = Number(M) - 1; const d = Number(D); return [`${D}.${M}.${Y}`, `${d}.${M}.${Y}`, `${MONTHS_EN[mi]} ${d}, ${Y}`, `${d} ${MONTHS_EN[mi]} ${Y}`, `${d} ${MONTHS_RU[mi]} ${Y}`, `${Y}-${M}-${D}`].some((x) => tx.includes(x)); });
256
+ dates.visible = /<time\b/i.test(html.slice(0, Math.round(html.length * 0.5))) || /(?<!\d)\d{1,2}[./]\d{1,2}[./](?:19|20)\d{2}(?!\d)|\d{1,2}\s+(?:января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря|January|February|March|April|May|June|July|August|September|October|November|December)\s+(?:19|20)\d{2}|(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s+(?:19|20)\d{2}/i.test(top); }
239
257
  const shortcode = /\[[a-z_-]+ [^\]]*\]/i.test(description) || /^[A-Za-z0-9+/=]{40,}$/.test(description);
240
258
  // Conversion path, read from the page itself. No analytics is needed to see whether a visitor
241
259
  // who wants to talk to this business can find a way to do it, and how far down it is.
@@ -248,6 +266,11 @@ function analysePage(url, html) {
248
266
  const contactPages = hrefsLower.filter((h) => /\/(contact|contacts|contact-us|kontakt|svyaz|contacto)\/?($|[?#])/.test(h)).length;
249
267
  const formEls = root.querySelectorAll('form');
250
268
  const realForms = formEls.filter((f) => !/search/i.test(`${f.getAttribute('class') || ''} ${f.getAttribute('id') || ''} ${f.getAttribute('role') || ''} ${f.getAttribute('action') || ''}`));
269
+ // Виджет записи без тега <form> (поля телефона и имени в обычном блоке) это тоже форма (14.10.2026, neronov.pro).
270
+ if (!realForms.length) {
271
+ const tel = root.querySelectorAll('input').find((i) => /tel/i.test(`${i.getAttribute('type') || ''} ${i.getAttribute('inputmode') || ''}`) || /phone|tel|телефон|номер|\+7|\+1/i.test(`${i.getAttribute('name') || ''} ${i.getAttribute('placeholder') || ''} ${Object.keys(i.attributes || {}).join(' ')}`));
272
+ if (tel) { let box = tel.parentNode; for (let k = 0; k < 4 && box && box.parentNode && box.querySelectorAll('input').length < 2; k++) box = box.parentNode; if (box) realForms.push(box); }
273
+ }
251
274
  const firstForm = realForms[0] || null;
252
275
  // A honeypot is invisible to a person, so counting it as a field the visitor must fill is wrong.
253
276
  const visibleToVisitor = (el) => {
@@ -294,11 +317,12 @@ function analysePage(url, html) {
294
317
  const priceShown = /"@type"\s*:\s*"(?:Offer|AggregateOffer)"|"price"\s*:\s*"?\d/.test(ld)
295
318
  // Нулевая сумма это пустая корзина в шапке («Spend $0.00 more»), а не цена: на brainmd.com она
296
319
  // стояла на каждой странице, и цена «нашлась» даже на странице контактов.
297
- || /(?:[$€£₽]\s?(?:[1-9]|0[.,]\d*[1-9])|(?<![\d.,])[1-9][\d\s.,]*\s?(?:₽|руб\.?|USD|EUR|GBP)(?![\p{L}]))/u.test(text);
320
+ || /(?:[$€£₽]\s?(?:[1-9]|0[.,]\d*[1-9])|(?<![\d.,])[1-9][\d\s.,]*\s?(?:₽|руб(?:\.|лей|ля|ль)?|USD|EUR|GBP)(?![\p{L}]))/u.test(text);
298
321
  // Trust signals a visitor and a rating system both look for, all of them public on the page.
299
322
  const sameAs = [...ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)].flatMap((m) => m[1].match(/https?:\/\/[^"\s,\]]+/g) || []);
300
323
  const trust = {
301
324
  postalAddress: /"(?:PostalAddress|streetAddress|addressLocality)"/.test(ld),
325
+ streetAddress: /"streetAddress"\s*:\s*"[^"]+"/.test(ld),
302
326
  telephone: /"telephone"\s*:/.test(ld) || telLinks > 0,
303
327
  email: /"email"\s*:/.test(ld) || mailLinks > 0,
304
328
  sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
@@ -309,10 +333,16 @@ function analysePage(url, html) {
309
333
  };
310
334
  // Answer-first family, the same measures the free AI visibility check uses, so the two agree.
311
335
  const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
312
- const firstPara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
336
+ // Без H1 первый абзац берём из тела страницы: иначе у главной без H1 «текста нет» (14.10.2026, medeq-mc.ru).
337
+ const firstPara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1])
338
+ || ([...bodyHtml.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => plain(m[1])).find((t) => (t.match(/[\p{L}]+/gu) || []).length >= 12) || '');
339
+ // Фраза, где сайт сам говорит, чем занимается: для строки «Чем занимается» при пустом описании (14.10.2026).
340
+ const offerSentence = (String(text || '').match(/[^.!?]{0,200}(?:предлага\p{L}*|оказыва\p{L}*|занимаемся|специализир\p{L}*|we offer|we provide|we specialise|we specialize)[^.!?]{0,200}[.!?]/iu) || [''])[0].trim().slice(0, 260);
341
+ const h2List = [...bodyHtml.matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi)].map((m) => plain(m[1]).trim()).filter(Boolean).slice(0, 10);
313
342
  const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
314
343
  const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && /\d/.test(firstPara);
315
344
  const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
345
+ const h3Count = (bodyHtml.match(/<h3[\s>]/gi) || []).length;
316
346
  const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
317
347
  // A source is named either in words, or the way careful writing actually does it: a link to
318
348
  // somebody else's site sitting in the same paragraph as the figure it backs.
@@ -338,7 +368,7 @@ function analysePage(url, html) {
338
368
  const citedParagraphs = paras.filter((x) => x.hrefs.some((h) => {
339
369
  try { return new URL(h, url).hostname.replace(/^www\./, '') !== ownHostname; } catch { return false; }
340
370
  })).length;
341
- return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: imgs.length, imagesNoAlt: imgsNoAlt, canonical, robots, viewport, og, twitter, generator, 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, trust };
371
+ return { url, title, citedParagraphs, figureParagraphs, titleLength: title.length, description, descriptionLength: description.length, descriptionGarbage: shortcode, h1Count: h1s.length, h1: h1s[0] || '', images: contentImgs.length + roleImgs.length, imagesNoAlt: imgsNoAlt, imagesAltMissing: imgsAltMissing, canonical, robots, viewport, og, twitter, generator, stack, htmlLang, hreflang, scripts, stylesheets, schemaTypes: [...new Set(schemaTypes)], words, links, dates, firstPara: firstPara.slice(0, 220), firstParaWords, answerFirst, h2Count, tables, sourcePhrases , telLinks, mailLinks, messengerLinks, contactPages, forms: realForms.length, formFields, formDepth, ctaFirstScreen, priceShown, h2List, offerSentence, h3Count, trust };
342
372
  }
343
373
 
344
374
  /** Карты сайта на живых коммерческих сайтах бывают вложенными десятками: у одного московского
@@ -564,7 +594,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
564
594
  const llmsForeign = llmsLinks.filter((u) => { try { return !ownHosts.has(new URL(u).host); } catch { return false; } });
565
595
  checks.push(row('llms', 'geo', 'llms.txt', !llms.ok ? 'warn' : !llmsIsText ? 'bad' : llmsForeign.length > llmsLinks.length / 2 && llmsLinks.length > 3 ? 'bad' : 'ok',
566
596
  !llms.ok ? 'not present' : !llmsIsText ? 'answers with an HTML page, not a text index' : `${llmsLinks.length} link(s), ${llmsForeign.length} to other hosts`,
567
- !llms.ok ? 'answer engines get nothing to read; a generated index is a one-day job' : llmsForeign.length > 3 ? `links to ${[...new Set(llmsForeign.map((u) => new URL(u).host))].slice(0, 3).join(', ')}: an AI system may misidentify the business` : ''));
597
+ !llms.ok ? 'there is no short site map for AI agents, so they have to work through the pages themselves; generating one is a one-day job' : llmsForeign.length > 3 ? `links to ${[...new Set(llmsForeign.map((u) => new URL(u).host))].slice(0, 3).join(', ')}: an AI system may misidentify the business` : ''));
568
598
 
569
599
  // Здоровье llms.txt: мы проверяем не только то, что файл есть, но и то, что он ведёт в живые
570
600
  // страницы. Индекс для ИИ разъезжается тихо: страницу переименовали, закрыли от индексации или
@@ -602,7 +632,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
602
632
  const agentCardOk = agentCard.ok && /^\s*\{/.test(agentCard.text || '');
603
633
  checks.push(row('agent-card', 'geo', 'Agent card (/.well-known/agent.json)', agentCardOk ? 'ok' : 'warn',
604
634
  agentCardOk ? 'present and valid JSON' : agentCard.ok ? 'present but not JSON' : 'not present',
605
- agentCardOk ? '' : 'an early, optional standard: it is the file agent directories read to learn who you are and what you offer'));
635
+ agentCardOk ? '' : 'an early, optional standard: the file describes a software AI agent, if the business runs one; without such an agent it is not needed'));
606
636
 
607
637
  const apiCatalog = await get(`${siteOrigin}/.well-known/api-catalog`);
608
638
  checks.push(row('api-catalog', 'geo', 'API catalogue (RFC 9727)', apiCatalog.ok ? 'ok' : 'warn',
@@ -721,7 +751,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
721
751
  checks.push(row('og-title', 'technical', 'og:title on the sampled pages', onAll((p) => p.og.title) === good.length ? 'ok' : onAll((p) => p.og.title) ? 'warn' : 'bad',
722
752
  `${onAll((p) => p.og.title)} of ${good.length} sampled page(s) carry og:title`, onAll((p) => p.og.title) === good.length ? '' : 'without it a shared link shows the raw URL or the wrong heading'));
723
753
  checks.push(row('og-image', 'technical', 'og:image on the sampled pages', onAll((p) => p.og.image) === good.length ? 'ok' : onAll((p) => p.og.image) ? 'warn' : 'bad',
724
- `${onAll((p) => p.og.image)} of ${good.length} sampled page(s) carry og:image`, onAll((p) => p.og.image) === good.length ? '' : 'a link without a picture is scrolled past in every messenger'));
754
+ `${onAll((p) => p.og.image)} of ${good.length} sampled page(s) carry og:image`, onAll((p) => p.og.image) === good.length ? '' : 'without og:image, messengers and social networks show the link without a picture'));
725
755
  checks.push(row('twitter-card', 'technical', 'Twitter Card', hp.twitter ? 'ok' : 'warn', hp.twitter ? `twitter:card ${hp.twitter}` : 'no twitter:card on the homepage',
726
756
  hp.twitter ? '' : 'X and several readers fall back to it when Open Graph is incomplete'));
727
757
  checks.push(row('schema', 'aeo', 'Structured data on homepage', hp.schemaTypes.length ? (hp.schemaTypes.includes('(invalid JSON-LD)') ? 'warn' : 'ok') : 'warn', hp.schemaTypes.join(', ') || 'no JSON-LD'));
@@ -750,14 +780,25 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
750
780
  const imgs = good.reduce((a, p) => a + p.images, 0); const noAlt = good.reduce((a, p) => a + p.imagesNoAlt, 0);
751
781
  if (imgs) checks.push(row('alt', 'onpage', 'Image alt text', noAlt / imgs > 0.5 ? 'bad' : noAlt / imgs > 0.2 ? 'warn' : 'ok', `${imgs - noAlt} of ${imgs} images carry alt text (${Math.round((1 - noAlt / imgs) * 100)}%) across ${good.length} sampled page(s)`));
752
782
  const multiH1 = good.filter((p) => p.h1Count > 1).length;
753
- if (good.length > 1) checks.push(row('h1-sample', 'onpage', 'Heading structure across sample', multiH1 ? (multiH1 > good.length / 2 ? 'bad' : 'warn') : 'ok', multiH1 ? `${multiH1} of ${good.length} pages carry more than one H1` : 'one H1 per page'));
783
+ // Страницы вовсе без H1 тоже считаются (14.10.2026): на medeq-mc.ru у 11 из 20 страниц H1 не было, а отчёт писал «по одному H1 на страницу».
784
+ const noH1 = good.filter((p) => !p.h1Count).length;
785
+ if (good.length > 1) {
786
+ const h1Status = noH1 > good.length / 4 || multiH1 > good.length / 2 ? 'bad' : noH1 || multiH1 ? 'warn' : 'ok';
787
+ const h1Value = noH1 || multiH1 ? [noH1 ? `${noH1} of ${good.length} sampled pages carry no H1` : '', multiH1 ? `${multiH1} of ${good.length} sampled pages carry more than one H1` : ''].filter(Boolean).join('; ') : 'one H1 on every sampled page';
788
+ checks.push(row('h1-sample', 'onpage', 'Heading structure across sample', h1Status, h1Value));
789
+ }
754
790
  const titles = new Map(); for (const p of good) titles.set(p.title, (titles.get(p.title) || 0) + 1);
755
791
  const dupTitles = [...titles].filter(([, n]) => n > 1);
756
792
  if (good.length > 1) checks.push(row('dup-titles', 'onpage', 'Duplicate titles in sample', dupTitles.length ? 'warn' : 'ok', dupTitles.length ? `${dupTitles.length} title(s) shared by several pages, e.g. "${dupTitles[0][0]}"` : 'every sampled page has its own title'));
757
793
  const thin = good.filter((p) => p.words < 300);
758
- if (good.length > 1) checks.push(row('thin', 'content', 'Thin pages in sample', thin.length > good.length / 2 ? 'bad' : thin.length ? 'warn' : 'ok', thin.length ? `${thin.length} of ${good.length} pages under 300 words` : `median ${median(good.map((p) => p.words))} words per page`));
794
+ if (good.length > 1) checks.push(row('thin', 'content', 'Thin pages in sample', thin.length > good.length / 2 ? 'bad' : thin.length ? 'warn' : 'ok', thin.length ? `${thin.length} of ${good.length} sampled pages under 300 words` : `median ${median(good.map((p) => p.words))} words per page`));
759
795
  const utility = sitemapPages.filter((u) => /\/(shop|cart|checkout|wishlist|compare|my-account|tag|author|feed)\/?/i.test(new URL(u).pathname));
760
- if (utility.length) checks.push(row('utility', 'technical', 'Utility pages in the sitemap', 'warn', `${utility.length} URL(s) such as ${new URL(utility[0]).pathname}`, 'cart, wishlist, tag and author pages add noise to the index'));
796
+ if (utility.length) {
797
+ // Пояснение называет только то, что нашлось: «корзина» у клиники без корзины выглядела копипастом (14.10.2026).
798
+ const KIND = [[/\/author\//i, 'author pages'], [/\/tag\//i, 'tag pages'], [/\/(cart|checkout)\b/i, 'cart and checkout pages'], [/\/wishlist|\/compare/i, 'wishlist pages'], [/\/my-account/i, 'account pages'], [/\/feed\b/i, 'feeds']];
799
+ const kinds = [...new Set(utility.flatMap((u) => KIND.filter(([re]) => re.test(new URL(u).pathname)).map(([, k]) => k)))];
800
+ checks.push(row('utility', 'technical', 'Utility pages in the sitemap', 'warn', utility.length === 1 ? `1 URL: ${new URL(utility[0]).pathname}` : `${utility.length} URL(s) such as ${new URL(utility[0]).pathname}`, `${kinds.join(', ') || 'utility pages'} add noise to the index`));
801
+ }
761
802
  const redirected = sample.filter((p) => p.redirect).length; const broken = sample.filter((p) => p.status && p.status >= 400).length;
762
803
  if (sitemapPages.length) checks.push(row('sitemap-health', 'technical', 'Sitemap URLs that answer 200', broken ? 'bad' : redirected ? 'warn' : 'ok', `${sample.length - redirected - broken} of ${sample.length} sampled URLs answer 200${redirected ? `, ${redirected} redirect` : ''}${broken ? `, ${broken} broken` : ''}`));
763
804
  if (good.length) {
@@ -799,14 +840,19 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
799
840
  }
800
841
  }
801
842
  const dated = good.filter((p) => p.dates.modified || p.dates.published).length;
802
- if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', dated ? 'ok' : 'warn', dated ? `${dated} of ${good.length} sampled pages expose dates` : 'no article dates in the sample: answer engines cannot tell what is current'));
843
+ // Дата в разметке и дата, которую видит человек, это разные вещи (14.10.2026): Yoast ставит datePublished
844
+ // на каждую страницу, а на странице услуги даты может не быть вовсе. Считаем обе и пишем обе.
845
+ // Видимая дата засчитывается, только если совпадает с датой в разметке: дата лицензии или чужой новости в боковом блоке это не дата страницы.
846
+ const seen = good.filter((p) => p.dates.visibleMatch).length;
847
+ if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', dated ? (seen >= dated / 2 ? 'ok' : 'warn') : 'warn', dated ? `${dated} of ${good.length} sampled pages carry a date in the markup; on ${seen} the same date is visible on the page` : 'no article dates in the sample: answer engines cannot tell what is current'));
803
848
  if (/wordpress/i.test(hp?.stack || '')) { const x = await get(`${siteOrigin}/xmlrpc.php`, { method: 'HEAD' }); if (x.status === 405 || x.status === 200) checks.push(row('xmlrpc', 'technical', 'xmlrpc.php', 'warn', `answers ${x.status}`, 'pingback endpoint open: attack surface with no SEO value')); }
804
849
 
805
850
  // ---- Off-page and trust ----------------------------------------------------------------
806
851
  // Link data comes from Semrush when SEMRUSH_API_KEY is set. Without it the three link rows are
807
852
  // reported as not measured and the area is still scored on the trust signals, which are free.
808
853
  log('off-page and trust');
809
- const pagesForTrust = hp ? [hp, ...good] : good;
854
+ // Главная часто входит и в выборку: без проверки адреса она считалась дважды, и выходило «21 из 21» при выборке в 20 (14.10.2026).
855
+ const pagesForTrust = hp && !good.some((p) => p.url === hp.url || p.url.replace(/\/$/, '') === String(hp.url).replace(/\/$/, '')) ? [hp, ...good] : good;
810
856
  const anyTrust = (f) => pagesForTrust.some(f);
811
857
  const links = await semrushBacklinks(host, log, opts.backlinks || null);
812
858
  if (links.ok) {
@@ -814,7 +860,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
814
860
  checks.push(row('links-profile', 'offpage', 'Backlink profile (optional extra)', 'na', `${links.domains} referring domain(s), ${links.total} link(s), ${followShare}% follow, authority score ${links.ascore} of 100`, `${links.source}. A paid tool, not required for this report and not counted in any score.`));
815
861
  }
816
862
  const addr = anyTrust((p) => p.trust?.postalAddress);
817
- checks.push(row('trust-entity', 'offpage', 'Who the business is, in machine-readable form', addr ? 'ok' : 'warn', addr ? 'a postal address is published in the structured data' : 'no postal address in the structured data', addr ? '' : 'rating systems and answer engines use it to tell one business from another'));
863
+ // Только город и страна это ещё не адрес (14.10.2026): полный адрес с улицей, иначе «спорно».
864
+ const street = anyTrust((p) => p.trust?.streetAddress);
865
+ checks.push(row('trust-entity', 'offpage', 'Who the business is, in machine-readable form', addr && street ? 'ok' : 'warn', addr && street ? 'a postal address is published in the structured data' : addr ? 'the structured data names only the city and country, no street address' : 'no postal address in the structured data', addr ? '' : 'rating systems and answer engines use it to tell one business from another'));
818
866
  // A published messenger is a direct channel like any other. Counting only phone and email would
819
867
  // mark a business that answers on Telegram all day as unreachable.
820
868
  const tel = anyTrust((p) => p.trust?.telephone);
@@ -834,7 +882,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
834
882
  const profileUrls = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].filter((u) => {
835
883
  try { const x = new URL(u); return PROFILE_HOST.test(x.host.replace(/^www\./, '')) && x.pathname.replace(/\/+$/, '').length > 1; } catch { return false; }
836
884
  });
837
- checks.push(row('trust-profiles', 'offpage', 'Profiles the site claims elsewhere', profiles.length >= 2 ? 'ok' : profiles.length ? 'warn' : 'bad', profiles.length ? `${profiles.length}: ${profiles.slice(0, 5).join(', ')}` : 'no sameAs links: the site claims no profile anywhere else', profiles.length ? '' : 'the cheapest off-site signal there is, and it is free'));
885
+ checks.push(row('trust-profiles', 'offpage', 'Profiles the site claims elsewhere', profiles.length >= 2 ? 'ok' : profiles.length ? 'warn' : 'bad', profiles.length ? `${profiles.length}: ${profiles.slice(0, 5).join(', ')}` : 'no sameAs links: the site claims no profile anywhere else', profiles.length ? '' : 'a free off-site signal: links to your own profiles in the markup'));
838
886
  const about = anyTrust((p) => p.trust?.aboutPage);
839
887
  checks.push(row('trust-about', 'offpage', 'A page that says who is behind the site', about ? 'ok' : 'warn', about ? 'linked from the sampled pages' : 'no about or team page is linked'));
840
888
  const policies = [...new Set(pagesForTrust.flatMap((p) => p.trust?.policyPages || []))];
@@ -852,7 +900,11 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
852
900
  if (depths.length) {
853
901
  const md = median(depths);
854
902
  checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', md < 50 ? 'ok' : md < 80 ? 'warn' : 'bad', `median ${md}% of the way down the page, measured on ${depths.length} page(s)`, md >= 80 ? 'on a long page almost nobody scrolls that far, least of all on a phone' : ''));
855
- } else checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', withForm.length ? 'na' : 'bad', withForm.length ? 'position could not be measured' : 'no form on the sampled pages'));
903
+ } else {
904
+ // Формы нет, но заявки идут через мессенджеры: это не провал, а другой способ (14.10.2026).
905
+ const viaMsg = pagesForTrust.filter((p) => p.messengerLinks).length;
906
+ checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', withForm.length || viaMsg ? 'na' : 'bad', withForm.length ? 'position could not be measured' : viaMsg ? `no form; requests go through messenger links on ${viaMsg} of ${pagesForTrust.length} sampled page(s)` : 'no form on the sampled pages'));
907
+ }
856
908
  const fields = withForm.map((p) => p.formFields).filter((n) => n > 0);
857
909
  if (fields.length) { const mf = median(fields); checks.push(row('conv-form-fields', 'conversion', 'What the form asks for', mf <= 4 ? 'ok' : mf <= 6 ? 'warn' : 'bad', `median ${mf} field(s) before a visitor can send anything`, mf > 4 ? 'every extra field costs replies, and a phone number costs the most' : '')); }
858
910
  const msg = pagesForTrust.filter((p) => p.messengerLinks > 0);
@@ -935,7 +987,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
935
987
  const ru = lang === 'ru';
936
988
  const pagesRu = (n) => (n % 10 === 1 && n % 100 !== 11 ? 'страница' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'страницы' : 'страниц');
937
989
  const S = ru ? {
938
- rows: ['Движок сайта', 'Страниц в карте сайта', 'Язык', 'Что продаётся'],
990
+ rows: ['Движок сайта', 'Страниц в карте сайта', 'Язык', 'Чем занимается'],
939
991
  partial: (n, read, total) => `не меньше ${n}: прочитано ${read} из ${total} файлов карты, остальные не успели`,
940
992
  site: 'Сам сайт', free: 'Бесплатно, без аккаунта',
941
993
  siteDetail: (n, viaLinks) => `robots.txt, карты сайта, llms.txt, главная и ${n} ${pagesRu(n)} выборки, прочитанные так же, как их читает любой посетитель${viaLinks ? `; ${viaLinks} из них найдены по ссылкам с главной, потому что в карте сайта их слишком мало` : ''}`,
@@ -991,7 +1043,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
991
1043
  reading,
992
1044
  /* Сырьё для готовых файлов: профили, которые сайт сам про себя заявляет, и есть ли контакты. */
993
1045
  entity: { profiles: profileUrls, hosts: profiles, telephone: Boolean(hp?.trust?.telephone), email: Boolean(hp?.trust?.email) },
994
- sample: sample.map((p) => p.title !== undefined ? { url: p.url, title: p.title, description: p.description, h1: p.h1, firstPara: p.firstPara, words: p.words, h1Count: p.h1Count, linkCount: Array.isArray(p.links) ? p.links.length : null, images: p.images, imagesNoAlt: p.imagesNoAlt, schemaTypes: p.schemaTypes, answerFirst: p.answerFirst, sourcePhrases: p.sourcePhrases, citedParagraphs: p.citedParagraphs, figureParagraphs: p.figureParagraphs, h2Count: p.h2Count, tables: p.tables, firstParaWords: p.firstParaWords, dates: p.dates, priceShown: p.priceShown, ctaFirstScreen: p.ctaFirstScreen } : p),
1046
+ sample: sample.map((p) => p.title !== undefined ? { url: p.url, title: p.title, description: p.description, h1: p.h1, firstPara: p.firstPara, words: p.words, h1Count: p.h1Count, linkCount: Array.isArray(p.links) ? p.links.length : null, images: p.images, imagesNoAlt: p.imagesNoAlt, imagesAltMissing: p.imagesAltMissing, schemaTypes: p.schemaTypes, answerFirst: p.answerFirst, sourcePhrases: p.sourcePhrases, citedParagraphs: p.citedParagraphs, figureParagraphs: p.figureParagraphs, h2Count: p.h2Count, tables: p.tables, firstParaWords: p.firstParaWords, dates: p.dates, priceShown: p.priceShown, ctaFirstScreen: p.ctaFirstScreen, h2List: p.h2List, offerSentence: p.offerSentence, h3Count: p.h3Count } : p),
995
1047
  notes,
996
1048
  };
997
1049
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Согласованность отчёта и файла задач перед выдачей (14.10.2026).
3
+ *
4
+ * Простым языком. Проверяющие на medeq-mc.ru раз за разом находили одно и то же семейство ошибок:
5
+ * в одном разделе «21 из 21», в другом выборка 20; скорость «до 0,8 с» в отчёте и «до 1,2 с» в
6
+ * задаче; «покупатели» у клиники; разделы 10 перед 09; оценка, которая не пересчитывается из
7
+ * перечисленных проверок. Всё это ловится без ИИ, сравнением текста с самим собой. Здесь каждое
8
+ * такое правило записано явно, и отчёт с хотя бы одним нарушением не печатается для клиента.
9
+ */
10
+ import { verifyScores } from './collect.mjs';
11
+ import { vocabFor } from './niche.mjs';
12
+
13
+ const strip = (html) => String(html || '').replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;|&#160;/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ');
14
+
15
+ /** Слова, которых не должно быть у ниши: советы чужой ниши в отчёте выглядят как копипаст. */
16
+ const FOREIGN = {
17
+ medical: [/покупател/i, /\bbuyers?\b/i, /корзин/i, /Получить расч[её]т/i, /Посмотреть цены/i, /RealEstateAgent/],
18
+ shop: [/RealEstateAgent/, /пациент/i, /\bpatients?\b/i],
19
+ realestate: [/пациент/i, /\bpatients?\b/i, /корзин/i],
20
+ b2b: [/пациент/i, /\bpatients?\b/i, /RealEstateAgent/],
21
+ services: [/пациент/i, /\bpatients?\b/i, /RealEstateAgent/],
22
+ other: [/RealEstateAgent/],
23
+ };
24
+
25
+ /**
26
+ * @param {object} audit
27
+ * @param {{ html?: string, tasks?: string }} parts отрисованный отчёт и текст файла задач
28
+ * @returns {{ ok: boolean, problems: string[] }}
29
+ */
30
+ export function consistencyCheck(audit, { html = '', tasks = '' } = {}) {
31
+ const problems = [];
32
+ const ru = audit?.meta?.lang === 'ru';
33
+ const report = strip(html);
34
+ const both = `${report}\n${tasks}`;
35
+ const sample = (audit?.sample || []).filter((p) => p && p.title !== undefined).length;
36
+
37
+ // 1. Оценки пересчитываются из проверок.
38
+ try { for (const m of verifyScores(audit) || []) problems.push(`оценка: ${m}`); } catch { /* старый формат без проверки */ }
39
+
40
+ // 2. «X из N страниц выборки»: N везде равно размеру выборки.
41
+ const re = ru ? /(\d+) из (\d+) (?:страниц\S*|проверенн\S*) выборки/g : /(\d+) of (\d+) sampled page/g;
42
+ for (const m of both.matchAll(re)) {
43
+ // «из 12 страниц выборки, где есть цифры» считает от другого основания, это не выборка.
44
+ const after = both.slice(m.index + m[0].length, m.index + m[0].length + 40);
45
+ if (/^,? (?:где есть цифры|carrying figures|with figures)/.test(after)) continue;
46
+ const n = Number(m[2]);
47
+ if (sample && n !== sample) problems.push(`выборка названа как ${n}, а прочитано ${sample}: «${m[0]}»`);
48
+ if (Number(m[1]) > n) problems.push(`больше, чем всего: «${m[0]}»`);
49
+ }
50
+
51
+ // 3. Один порог на весь текст.
52
+ const ttfbThresholds = [...both.matchAll(ru ? /(?:до|не больше|меньше)\s+(\d+(?:,\d+)?|одной)\s+(?:секунд|с\b)/g : /(?:under|at most|or less|within)\s+(\d+(?:\.\d+)?)\s*(?:seconds?|s\b)/g)]
53
+ .map((m) => (m[1] === 'одной' ? '1' : m[1].replace(',', '.')));
54
+ const distinct = [...new Set(ttfbThresholds.filter((x) => Number(x) < 3))];
55
+ if (distinct.length > 1) problems.push(`разные пороги времени ответа: ${distinct.join(', ')} с`);
56
+ // Длина описания: только там, где рядом слово «описание» (у заголовка свой порог 30-60).
57
+ const descr = [...both.matchAll(/(?:описани|description)[^.]{0,80}?(?:от\s+)?(\d{2,3})\s*(?:-|до|to)\s*(\d{2,3})\s+(?:знак|char)/gi)].map((m) => `${m[1]}-${m[2]}`);
58
+ if (new Set(descr).size > 1) problems.push(`разные длины описания: ${[...new Set(descr)].join(', ')}`);
59
+ const cta = [...both.matchAll(ru ? /(?:первых|верхних)\s+(\d+|тридцати|пятнадцати)\s+процент/g : /(?:top|first)\s+(\d+|thirty|fifteen)\s+percent/g)].map((m) => m[1]);
60
+ if (new Set(cta).size > 1) problems.push(`разные доли первого экрана: ${[...new Set(cta)].join(', ')}`);
61
+
62
+ // 4. Слова чужой ниши.
63
+ const v = vocabFor(audit, ru ? 'ru' : 'en');
64
+ for (const bad of FOREIGN[v.niche] || []) {
65
+ const hit = both.match(bad);
66
+ if (hit) problems.push(`слово не из ниши «${v.niche}»: «${hit[0]}»`);
67
+ }
68
+
69
+ // 5. Разделы по порядку.
70
+ // Номера берутся только из надписей разделов (class="eyebrow"), а не из любого «35 · Слово» в тексте.
71
+ const nums = [...String(html).matchAll(/class="eyebrow">\s*(\d{2})\s?·/g)].map((m) => Number(m[1]));
72
+ for (let i = 1; i < nums.length; i++) if (nums[i] < nums[i - 1]) { problems.push(`раздел ${String(nums[i]).padStart(2, '0')} стоит после ${String(nums[i - 1]).padStart(2, '0')}`); break; }
73
+
74
+ // 6. Заготовки и служебный текст не уходят клиенту.
75
+ for (const bad of [/\{\{/, /на этой машине/i, /on this machine/i, /undefined/, /NaN/]) {
76
+ const hit = both.match(bad);
77
+ if (hit) problems.push(`служебный текст в отчёте: «${hit[0]}»`);
78
+ }
79
+
80
+ // 7. Тире, которых в текстах для клиента быть не должно (кроме цитат с сайта клиента в таблице страниц).
81
+ // Тире внутри цитат с сайта клиента («…», "…") это его текст, не наш.
82
+ const dashes = (tasks.replace(/«[^»]*»|"[^"]*"|&quot;[^&]*&quot;/g, '').match(/[—–]/g) || []).length;
83
+ if (dashes) problems.push(`длинное или среднее тире в файле задач: ${dashes}`);
84
+
85
+ // 8. Задачи, где «Как проверить» общее: это не проверка.
86
+ const generic = (tasks.match(ru ? /Прогони проверку сайта заново/g : /Run the site check again/g) || []).length;
87
+ if (generic) problems.push(`общая строка «Как проверить» вместо конкретной: ${generic}`);
88
+
89
+ return { ok: problems.length === 0, problems };
90
+ }
package/src/etalon.mjs ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Эталонный набор (14.10.2026): записанные ответы реальных сайтов и проверенные вручную ответы.
3
+ *
4
+ * Простым языком. На пяти сайтах (medeq-mc.ru, brainmd.com, neronov.pro, drivee.com и ещё одном)
5
+ * мы руками нашли ошибки движка. Здесь их ответы записаны в файл, а движок прогоняется по записи
6
+ * без сети: сбор, второй взгляд по сайту, черновик. Каждое правильное значение, сверенное вручную,
7
+ * проверяется. Новая версия движка не выкладывается, если хоть одно разошлось.
8
+ */
9
+ import { gzipSync, gunzipSync } from 'node:zlib';
10
+ import { readFileSync, writeFileSync } from 'node:fs';
11
+
12
+ const keyOf = (input, init = {}) => `${String(init.method || 'GET').toUpperCase()} ${typeof input === 'string' ? input : input.url}`;
13
+
14
+ /** Запись: подменяет fetch, складывает каждый ответ. */
15
+ export function recorder(realFetch = globalThis.fetch) {
16
+ const store = {};
17
+ const fetchRec = async (input, init = {}) => {
18
+ const r = await realFetch(input, init);
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; }
21
+ store[keyOf(input, init)] = { status: r.status, url: r.url, headers, body };
22
+ return new Response([101, 204, 205, 304].includes(r.status) ? null : body, { status: r.status, headers });
23
+ };
24
+ return { fetch: fetchRec, save: (file) => writeFileSync(file, gzipSync(JSON.stringify(store))), store };
25
+ }
26
+
27
+ /** Воспроизведение: тот же fetch, только из файла. Чего нет в записи, отвечает 404. */
28
+ export function replayer(file) {
29
+ const store = JSON.parse(gunzipSync(readFileSync(file)).toString('utf8'));
30
+ return async (input, init = {}) => {
31
+ const hit = store[keyOf(input, init)] || store[`GET ${typeof input === 'string' ? input : input.url}`];
32
+ if (!hit) return new Response('', { status: 404 });
33
+ const res = new Response([101, 204, 205, 304].includes(hit.status) ? null : hit.body, { status: hit.status, headers: hit.headers });
34
+ Object.defineProperty(res, 'url', { value: hit.url || (typeof input === 'string' ? input : input.url) });
35
+ return res;
36
+ };
37
+ }
package/src/i18n.mjs CHANGED
@@ -23,6 +23,8 @@ const plural = (n, one, few, many) => {
23
23
  const pct = (n) => plural(Number(n), 'процент', 'процента', 'процентов');
24
24
  const pages = (n) => plural(Number(n), 'страница', 'страницы', 'страниц');
25
25
  const pagesOf = (a, b) => `${a} из ${pages(b)}`;
26
+ /** «из 21 страницы», «из 20 страниц»: после «из» родительный падеж. */
27
+ const pagesGen = (n) => (Number(n) % 10 === 1 && Number(n) % 100 !== 11 ? 'страницы' : 'страниц');
26
28
 
27
29
  /** Перевод «сколько проверок пройдено» из подписи под оценкой. */
28
30
  export function localiseBasisNote(note) {
@@ -80,27 +82,27 @@ const RU = {
80
82
  },
81
83
  },
82
84
  'sitemap-foreign': { label: 'В карте сайта чужие хосты', value: (v) => `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')} на других хостах` },
83
- 'sitemap-health': { label: 'Адреса из карты сайта отвечают 200', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} проверенных адресов отвечают 200${/redirect/.test(v) ? `, ${n[2]} перенаправляют` : ''}${/broken/.test(v) ? ', есть битые' : ''}`; } },
85
+ 'sitemap-health': { label: 'Адреса из карты сайта отвечают 200', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} адресов выборки из карты сайта отвечают 200${/redirect/.test(v) ? `, ${n[2]} перенаправляют` : ''}${/broken/.test(v) ? ', есть битые' : ''}`; } },
84
86
  llms: {
85
87
  label: 'llms.txt',
86
88
  value: (v) => (/not present/.test(v) ? 'нет'
87
89
  : /HTML page/.test(v) ? 'отдаёт HTML-страницу вместо текстового индекса'
88
90
  : `${plural(Number(nums(v)[0]), 'ссылка', 'ссылки', 'ссылок')}, ${nums(v)[1]} на другие хосты`),
89
- comment: (c) => (/nothing to read/.test(c) ? 'ответным системам нечего читать, генерация индекса это работа на день' : c ? 'система ИИ может принять сайт за чужой' : ''),
91
+ comment: (c) => (/no short site map for AI agents|nothing to read/.test(c) ? 'короткой карты сайта для ИИ-агентов нет, им приходится разбирать страницы самим; собрать её это работа на день' : c ? 'система ИИ может принять сайт за чужой' : ''),
90
92
  },
91
93
  // Где ноль означает отсутствие элемента, по-русски это тоже пишется словами, а не цифрой:
92
94
  // «0 знаков» в задании для исполнителя читается как обрывок.
93
- title: { label: 'Заголовок главной', value: (v) => { if (/^no title/i.test(v)) return 'заголовка у страницы нет'; const n = nums(v); return `${plural(Number(n[n.length - 1]), 'знак', 'знака', 'знаков')}${Number(n[n.length - 1]) > 60 ? ', в выдаче обрежется' : ', укладывается в выдачу'}`; }, comment: (c) => (/^short/.test(c) ? 'коротко: в заголовке нет слов, ради которых по нему кликают' : /cut in results/.test(c) ? 'в выдаче обрежется' : '') },
95
+ title: { label: 'Заголовок вкладки главной', value: (v) => { if (/^no title/i.test(v)) return 'заголовка у страницы нет'; const n = nums(v); return `${plural(Number(n[n.length - 1]), 'знак', 'знака', 'знаков')}${Number(n[n.length - 1]) > 60 ? ', в выдаче обрежется' : ', укладывается в выдачу'}`; }, comment: (c) => (/^short/.test(c) ? 'коротко: в заголовке нет слов, ради которых по нему кликают' : /cut in results/.test(c) ? 'в выдаче обрежется' : '') },
94
96
  description: { label: 'Описание главной', value: (v) => (/^no meta description/i.test(v) ? 'описания у страницы нет' : /missing|none/i.test(v) ? 'нет' : /shortcode|garbage/i.test(v) ? 'содержит служебный код вместо текста' : `${plural(Number(nums(v)[0]), 'знак', 'знака', 'знаков')}`), comment: (c) => (/technical garbage/.test(c) ? 'в сниппете покажется технический мусор вместо текста' : /missing/.test(c) ? 'описания нет' : '') },
95
97
  h1: { label: 'Заголовок H1 на главной', value: (v) => (/^no H1/i.test(v) ? 'заголовка H1 на странице нет' : `${plural(Number(nums(v)[0] || 0), 'заголовок', 'заголовка', 'заголовков')} H1`), comment: (c) => (/dilutes/.test(c) ? 'больше одного H1 размывает тему страницы' : /no H1/.test(c) ? 'H1 на странице нет' : '') },
96
98
  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*/, '')}` : '') },
97
99
  // Добавлено 17.09.2026 вместе с новыми проверками. Русский отчёт платный, английские строки в нём
98
100
  // это такой же брак, как русский отчёт англоязычному покупателю.
99
- 'title-all': { label: 'Заголовок на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
101
+ 'title-all': { label: 'Заголовок вкладки на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
100
102
  'description-all': { label: 'Описание на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
101
103
  'schema-all': { label: 'Разметка на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
102
104
  'og-title': { label: 'og:title на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (c ? 'без него при пересылке ссылки видно голый адрес или чужой заголовок' : '') },
103
- 'og-image': { label: 'og:image на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (c ? 'ссылку без картинки пролистывают в любом мессенджере' : '') },
105
+ 'og-image': { label: 'og:image на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (c ? 'без og:image мессенджеры и соцсети показывают ссылку без картинки' : '') },
104
106
  'twitter-card': { label: 'Карточка Twitter', value: (v) => (/no twitter/.test(v) ? 'не задана' : v.replace('twitter:card', 'тип')), comment: (c) => (c ? 'на неё опираются X и часть читалок, когда Open Graph неполный' : '') },
105
107
  'breadcrumbs-schema': { label: 'Разметка хлебных крошек', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${pages(nums(v)[0])} выборки`), comment: (c) => (c ? 'крошки говорят системе, где страница стоит, и показываются в ответе рядом со ссылкой' : '') },
106
108
  'howto-schema': { label: 'Разметка пошаговых инструкций', value: (v) => (/no step-by-step/.test(v) ? 'пошаговых страниц в выборке нет' : `есть на ${pages(nums(v)[0])} выборки`), comment: (c) => (c ? 'ставить только там, где страница правда ведёт по шагам: разметить ею что-то другое значит соврать системе' : '') },
@@ -122,11 +124,11 @@ const RU = {
122
124
  hreflang: { label: 'hreflang', value: (v) => (/none/.test(v) ? 'нет, сайт одноязычный' : `${plural(Number(nums(v)[0]), 'альтернатива', 'альтернативы', 'альтернатив')}`) },
123
125
  cms: { label: 'Движок сайта', value: (v) => v.replace(' (detected from the page code)', ' (определён по коду страницы)') },
124
126
  xmlrpc: { label: 'xmlrpc.php', value: (v) => `отвечает ${nums(v)[0]}`, comment: () => 'открытая точка для атак, пользы для поиска никакой' },
125
- alt: { label: 'Подписи к картинкам', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} картинок имеют подпись, это ${pct(n[2])}, на ${pages(n[3])} выборки`; } },
126
- 'h1-sample': { label: 'Структура заголовков', value: (v) => (/one H1 per page/.test(v) ? 'по одному H1 на страницу' : `${nums(v)[0]} из ${pages(nums(v)[1])} имеют больше одного H1`) },
127
+ 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 ? 'странице' : 'страницах'} выборки`; } },
128
+ '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('; ')) },
127
129
  'dup-titles': { label: 'Повторы заголовков', value: (v) => (/its own title/.test(v) ? 'у каждой проверенной страницы свой заголовок' : `${plural(Number(nums(v)[0]), 'заголовок повторяется', 'заголовка повторяются', 'заголовков повторяются')} на разных страницах`) },
128
- thin: { label: 'Тонкие страницы', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${pages(nums(v)[1])} короче 300 слов`) },
129
- utility: { label: 'Служебные страницы в карте сайта', value: (v) => `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')}, например ${v.split('such as ')[1] || ''}`, comment: () => 'корзина, избранное, теги и страницы авторов засоряют индекс' },
130
+ thin: { label: 'Тонкие страницы', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки короче 300 слов`) },
131
+ 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(', ')}: в индексе не нужны и засоряют его`; } },
130
132
  'answer-first': {
131
133
  label: 'Ответ в первом абзаце',
132
134
  value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки начинаются с 20-90 слов, где есть цифра, сразу после H1`; },
@@ -134,25 +136,25 @@ const RU = {
134
136
  },
135
137
  sections: { label: 'Разбивка на разделы', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки имеют три и более подзаголовка H2`; }, comment: (c) => (c ? 'системы цитируют разделы, а не сплошной текст' : '') },
136
138
  tables: { label: 'Таблицы в тексте', value: (v) => (/no table/.test(v) ? 'на проверенных страницах таблиц нет' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки используют таблицу`), comment: (c) => (c ? 'таблица это второй по частоте формат цитирования после первого абзаца' : '') },
137
- sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки называют, откуда взята цифра, словами или ссылкой рядом с ней`; }, comment: (c) => (c ? 'цифра без источника это первое, что система выбрасывает' : '') },
138
- dates: { label: 'Открытые даты публикации', value: (v) => (/no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки показывают дату`) },
139
+ 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 ? 'цифра без источника это первое, что система выбрасывает' : '') },
140
+ 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])} выборки показывают дату`) },
139
141
  '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 ? 'ИИ читает этот файл вместо обхода сайта: каждая мёртвая или закрытая запись это страница, которую он процитирует неверно или не процитирует вовсе' : '') },
140
- 'agent-card': { label: 'Карточка агента (/.well-known/agent.json)', value: (v) => (/valid JSON/.test(v) ? 'есть, и это правильный JSON' : /not JSON/.test(v) ? 'есть, но это не JSON' : 'нет'), comment: (c) => (c ? 'ранний и необязательный стандарт: этот файл читают каталоги ИИ-агентов, чтобы понять, кто вы и что предлагаете' : '') },
142
+ 'agent-card': { label: 'Карточка агента (/.well-known/agent.json)', value: (v) => (/valid JSON/.test(v) ? 'есть, и это правильный JSON' : /not JSON/.test(v) ? 'есть, но это не JSON' : 'нет'), comment: (c) => (c ? 'ранний и необязательный стандарт: файл описывает программного ИИ-агента, если он у компании есть; без своего агента он не нужен' : '') },
141
143
  'api-catalog': { label: 'Каталог API (RFC 9727)', value: (v) => (/^present/.test(v) ? 'есть' : 'нет'), comment: (c) => (c ? 'необязательно, и нужно только если у бизнеса действительно есть API, который агент мог бы вызвать' : '') },
142
144
  'agent-markdown': { label: 'Markdown-версия страниц', value: (v) => (/Link header/.test(v) ? 'предлагается через заголовок Link' : /next to the page/.test(v) ? 'лежит рядом со страницей' : 'не предлагается'), comment: (c) => (c ? 'агент, который просит text/markdown, получает HTML и вынужден его разбирать: отдать markdown дёшево и снимает догадки' : '') },
143
145
  'links-profile': { label: 'Профиль внешних ссылок (необязательное дополнение)', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'ссылающийся домен', 'ссылающихся домена', 'ссылающихся доменов')}, ${plural(Number(n[1]), 'ссылка', 'ссылки', 'ссылок')}, ${pct(n[2])} передают вес, показатель авторитета ${n[3]} из 100`; }, comment: (c) => `${c.split('.')[0]}. Платный инструмент, для этого отчёта не обязателен и ни в одну оценку не входит.` },
144
- 'trust-entity': { label: 'Кто это, в машиночитаемом виде', value: (v) => (/no postal address/.test(v) ? 'почтового адреса в разметке нет' : 'почтовый адрес опубликован в разметке'), comment: (c) => (c ? 'рейтинговые системы и ответные движки по нему отличают одну компанию от другой' : '') },
146
+ 'trust-entity': { label: 'Кто это, в машиночитаемом виде', value: (v) => (/no postal address/.test(v) ? 'почтового адреса в разметке нет' : /only the city and country/.test(v) ? 'в разметке только город и страна, без улицы' : 'почтовый адрес опубликован в разметке'), comment: (c) => (c ? 'рейтинговые системы и ответные движки по нему отличают одну компанию от другой' : '') },
145
147
  'trust-contact': {
146
148
  label: 'Прямые контакты',
147
149
  value: (v) => (/no phone, email or messenger/.test(v) ? 'ни телефона, ни почты, ни мессенджера не опубликовано'
148
150
  : v.replace(/\bphone\b/g, 'телефон').replace(/\bemail\b/g, 'почта').replace(/a messenger/g, 'мессенджер').replace(/ and /g, ' и ')),
149
151
  },
150
- 'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' : `${nums(v)[0]}: ${v.split(': ').slice(1).join(': ')}`), comment: (c) => (c ? 'самый дешёвый внешний сигнал, и он бесплатный' : '') },
152
+ 'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' : `${nums(v)[0]}: ${v.split(': ').slice(1).join(': ')}`), comment: (c) => (c ? 'бесплатный внешний сигнал: ссылки на свои профили в разметке' : '') },
151
153
  'trust-about': { label: 'Страница о компании', value: (v) => (/no about/.test(v) ? 'страницы о компании нет или на неё не ведут ссылки' : 'на неё ведут ссылки с проверенных страниц') },
152
154
  '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, 'согласие на обработку данных')}`) },
153
155
  'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки дают форму, контактную ссылку или страницу контактов`; } },
154
156
  'conv-cta': { label: 'Призыв к действию в первом экране', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки ставят его в первых ${n[2]} процентах страницы`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
155
- 'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${pct(nums(v)[0])} вниз по странице, измерено на ${pages(nums(v)[1])}`), comment: (c) => (c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
157
+ 'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/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])} вниз по странице, измерено на ${pages(nums(v)[1])}`), comment: (c) => (c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
156
158
  'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`, comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
157
159
  'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
158
160
  };
@@ -172,18 +174,22 @@ export function localiseChecks(checks, lang = 'ru') {
172
174
  const r = RU[c.id];
173
175
  if (!r) return c;
174
176
  const out = { ...c, label: r.label || c.label };
175
- try { if (r.value) out.value = r.value(c.value ?? ''); } catch { /* оставляем исходный текст */ }
176
- try { if (r.comment) out.comment = r.comment(c.comment ?? ''); } catch { /* оставляем исходный текст */ }
177
+ // Уже русский текст (его пишет второй взгляд по сайту) второй раз не переводим: перевод ждёт английский шаблон и испортил бы строку.
178
+ // Строки, которые уже написаны по-русски (второй взгляд по сайту ставит флаг localised), второй раз не переводим.
179
+ // Уже русская строка (без английских слов вне кавычек) второй раз не переводится: перевод ждёт английский шаблон.
180
+ const ruAlready = (x) => { const t = String(x || '').replace(/"[^"]*"|«[^»]*»/g, ''); return Boolean(c.localised) || (/[А-Яа-яЁё]/.test(t) && !/[A-Za-z]{3,}/.test(t)); };
181
+ try { if (r.value && !ruAlready(c.value)) out.value = r.value(c.value ?? ''); } catch { /* оставляем исходный текст */ }
182
+ try { if (r.comment && !ruAlready(c.comment)) out.comment = r.comment(c.comment ?? ''); } catch { /* оставляем исходный текст */ }
177
183
  return out;
178
184
  });
179
185
  }
180
186
 
181
187
  /** Русские названия шести областей. */
182
188
  export const AREAS_RU = {
183
- 'SEO, technical': 'SEO, техническая часть',
189
+ 'SEO, technical': 'SEO, доступ и адреса сайта',
184
190
  'SEO, content and structure': 'SEO, контент и структура',
185
191
  'AEO, answers and snippets': 'AEO, ответы и сниппеты',
186
- 'GEO, visibility in AI systems': 'GEO, видимость в системах ИИ',
192
+ 'GEO, visibility in AI systems': 'GEO, доступ и разметка для ИИ',
187
193
  'Off-page and trust': 'Ссылки и доверие',
188
194
  'Conversion and UX': 'Конверсия и удобство',
189
195
  };
package/src/index.mjs CHANGED
@@ -9,3 +9,9 @@ export { draftNarrative, stillEmpty } from './narrative.mjs';
9
9
  export { agentPrompt, agentPrompts, renderAgentPrompts, firstFixParts, FIRST_FIX_IDS } from './prompts.mjs';
10
10
  export { buildDemandMap, expandQueries, coverage, seedsFromPages, volumesViaTopvisor, topvisorClient, renderDemandHtml, renderDemandMarkdown, demandSourceRow, demandCsv, demandTotals, REGIONS as DEMAND_REGIONS, DEMAND_DEFAULTS } from './demand.mjs';
11
11
  export { buildLlmsTxt, buildOrganisationSchema, fixTitlesAndDescriptions } from './handover.mjs';
12
+ export { secondLook, siteDigest, rescore, rebuildCritical, assertVerified, coldTtfb } from './verify.mjs';
13
+ export { reviewAndFix, reviewRound, confirmFlags, statementsOf, applyFix, toolFacts } from './review.mjs';
14
+ export { consistencyCheck } from './consistency.mjs';
15
+ export { detectNiche, vocabFor } from './niche.mjs';
16
+ export { renderTasksHtml, renderTasksPdf } from './tasks-pdf.mjs';
17
+ export { printFitted, pdfPageCount } from './render.mjs';