@operstack/audit 0.33.4 → 0.34.1
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 +8 -3
- package/src/collect.mjs +76 -20
- package/src/consistency.mjs +90 -0
- package/src/etalon.mjs +37 -0
- package/src/i18n.mjs +25 -19
- package/src/index.mjs +6 -0
- package/src/narrative.mjs +42 -17
- package/src/niche.mjs +91 -0
- package/src/prompts.mjs +155 -5
- package/src/render.mjs +74 -16
- package/src/rendered.mjs +4 -1
- package/src/review.mjs +275 -0
- package/src/tasks-pdf.mjs +67 -0
- package/src/test-agent-surface.mjs +1 -1
- package/src/test-etalon.mjs +42 -0
- package/src/test-narrative.mjs +2 -2
- package/src/test-prompts.mjs +2 -2
- package/src/test-rendered.mjs +1 -1
- package/src/verify.mjs +354 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.1",
|
|
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
|
-
|
|
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
|
-
|
|
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?(
|
|
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
|
-
|
|
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:
|
|
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 ? '
|
|
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:
|
|
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 ? '' : '
|
|
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,27 @@ 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
|
-
|
|
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
|
-
|
|
758
|
-
|
|
793
|
+
// Список статей, отзывы с виджетом, рубрики короткие по устройству: их не считаем тонкими (14.10.2026, neronov.pro).
|
|
794
|
+
const LISTY = /\/(articles|blog|news|novosti|stati|otzyvy|reviews|testimonials|category|tag|page\/\d+)\/?$/i;
|
|
795
|
+
const thin = good.filter((p) => p.words < 300 && !LISTY.test(new URL(p.url).pathname));
|
|
796
|
+
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
797
|
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)
|
|
798
|
+
if (utility.length) {
|
|
799
|
+
// Пояснение называет только то, что нашлось: «корзина» у клиники без корзины выглядела копипастом (14.10.2026).
|
|
800
|
+
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']];
|
|
801
|
+
const kinds = [...new Set(utility.flatMap((u) => KIND.filter(([re]) => re.test(new URL(u).pathname)).map(([, k]) => k)))];
|
|
802
|
+
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`));
|
|
803
|
+
}
|
|
761
804
|
const redirected = sample.filter((p) => p.redirect).length; const broken = sample.filter((p) => p.status && p.status >= 400).length;
|
|
762
805
|
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
806
|
if (good.length) {
|
|
@@ -799,14 +842,19 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
799
842
|
}
|
|
800
843
|
}
|
|
801
844
|
const dated = good.filter((p) => p.dates.modified || p.dates.published).length;
|
|
802
|
-
|
|
845
|
+
// Дата в разметке и дата, которую видит человек, это разные вещи (14.10.2026): Yoast ставит datePublished
|
|
846
|
+
// на каждую страницу, а на странице услуги даты может не быть вовсе. Считаем обе и пишем обе.
|
|
847
|
+
// Видимая дата засчитывается, только если совпадает с датой в разметке: дата лицензии или чужой новости в боковом блоке это не дата страницы.
|
|
848
|
+
const seen = good.filter((p) => p.dates.visibleMatch).length;
|
|
849
|
+
if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', dated ? (seen >= dated / 2 ? 'ok' : 'warn') : 'warn', dated ? `${dated} of ${good.length} sampled pages carry a date in the markup; on ${seen} the same date is visible on the page` : 'no article dates in the sample: answer engines cannot tell what is current'));
|
|
803
850
|
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
851
|
|
|
805
852
|
// ---- Off-page and trust ----------------------------------------------------------------
|
|
806
853
|
// Link data comes from Semrush when SEMRUSH_API_KEY is set. Without it the three link rows are
|
|
807
854
|
// reported as not measured and the area is still scored on the trust signals, which are free.
|
|
808
855
|
log('off-page and trust');
|
|
809
|
-
|
|
856
|
+
// Главная часто входит и в выборку: без проверки адреса она считалась дважды, и выходило «21 из 21» при выборке в 20 (14.10.2026).
|
|
857
|
+
const pagesForTrust = hp && !good.some((p) => p.url === hp.url || p.url.replace(/\/$/, '') === String(hp.url).replace(/\/$/, '')) ? [hp, ...good] : good;
|
|
810
858
|
const anyTrust = (f) => pagesForTrust.some(f);
|
|
811
859
|
const links = await semrushBacklinks(host, log, opts.backlinks || null);
|
|
812
860
|
if (links.ok) {
|
|
@@ -814,7 +862,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
814
862
|
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
863
|
}
|
|
816
864
|
const addr = anyTrust((p) => p.trust?.postalAddress);
|
|
817
|
-
|
|
865
|
+
// Только город и страна это ещё не адрес (14.10.2026): полный адрес с улицей, иначе «спорно».
|
|
866
|
+
const street = anyTrust((p) => p.trust?.streetAddress);
|
|
867
|
+
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
868
|
// A published messenger is a direct channel like any other. Counting only phone and email would
|
|
819
869
|
// mark a business that answers on Telegram all day as unreachable.
|
|
820
870
|
const tel = anyTrust((p) => p.trust?.telephone);
|
|
@@ -834,7 +884,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
834
884
|
const profileUrls = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].filter((u) => {
|
|
835
885
|
try { const x = new URL(u); return PROFILE_HOST.test(x.host.replace(/^www\./, '')) && x.pathname.replace(/\/+$/, '').length > 1; } catch { return false; }
|
|
836
886
|
});
|
|
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 ? '' : '
|
|
887
|
+
checks.push(row('trust-profiles', 'offpage', 'Profiles the site claims elsewhere', profiles.length >= 2 ? 'ok' : profiles.length ? 'warn' : 'bad', profiles.length ? `${profiles.length}: ${profiles.slice(0, 5).join(', ')}` : 'no sameAs links: the site claims no profile anywhere else', profiles.length ? '' : 'a free off-site signal: links to your own profiles in the markup'));
|
|
838
888
|
const about = anyTrust((p) => p.trust?.aboutPage);
|
|
839
889
|
checks.push(row('trust-about', 'offpage', 'A page that says who is behind the site', about ? 'ok' : 'warn', about ? 'linked from the sampled pages' : 'no about or team page is linked'));
|
|
840
890
|
const policies = [...new Set(pagesForTrust.flatMap((p) => p.trust?.policyPages || []))];
|
|
@@ -851,8 +901,14 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
851
901
|
const depths = withForm.map((p) => p.formDepth).filter((d) => d !== null && d !== undefined);
|
|
852
902
|
if (depths.length) {
|
|
853
903
|
const md = median(depths);
|
|
854
|
-
|
|
855
|
-
|
|
904
|
+
// Если кнопка действия стоит на первом экране почти везде, форма внизу не беда: к ней ведёт кнопка (14.10.2026, neronov.pro).
|
|
905
|
+
const ctaShare = pagesForTrust.length ? pagesForTrust.filter((p) => p.ctaFirstScreen).length / pagesForTrust.length : 0;
|
|
906
|
+
checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', md < 50 || ctaShare >= 0.8 ? 'ok' : md < 80 ? 'warn' : 'bad', `median ${md}% of the way down the page, measured on ${depths.length} page(s)`, ctaShare >= 0.8 && md >= 50 ? 'the first screen already has a button that leads to it' : md >= 80 ? 'on a long page almost nobody scrolls that far, least of all on a phone' : ''));
|
|
907
|
+
} else {
|
|
908
|
+
// Формы нет, но заявки идут через мессенджеры: это не провал, а другой способ (14.10.2026).
|
|
909
|
+
const viaMsg = pagesForTrust.filter((p) => p.messengerLinks).length;
|
|
910
|
+
checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', withForm.length || viaMsg ? 'na' : 'bad', withForm.length ? 'position could not be measured' : viaMsg ? `no form; requests go through messenger links on ${viaMsg} of ${pagesForTrust.length} sampled page(s)` : 'no form on the sampled pages'));
|
|
911
|
+
}
|
|
856
912
|
const fields = withForm.map((p) => p.formFields).filter((n) => n > 0);
|
|
857
913
|
if (fields.length) { const mf = median(fields); checks.push(row('conv-form-fields', 'conversion', 'What the form asks for', mf <= 4 ? 'ok' : mf <= 6 ? 'warn' : 'bad', `median ${mf} field(s) before a visitor can send anything`, mf > 4 ? 'every extra field costs replies, and a phone number costs the most' : '')); }
|
|
858
914
|
const msg = pagesForTrust.filter((p) => p.messengerLinks > 0);
|
|
@@ -935,7 +991,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
935
991
|
const ru = lang === 'ru';
|
|
936
992
|
const pagesRu = (n) => (n % 10 === 1 && n % 100 !== 11 ? 'страница' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'страницы' : 'страниц');
|
|
937
993
|
const S = ru ? {
|
|
938
|
-
rows: ['Движок сайта', 'Страниц в карте сайта', 'Язык', '
|
|
994
|
+
rows: ['Движок сайта', 'Страниц в карте сайта', 'Язык', 'Чем занимается'],
|
|
939
995
|
partial: (n, read, total) => `не меньше ${n}: прочитано ${read} из ${total} файлов карты, остальные не успели`,
|
|
940
996
|
site: 'Сам сайт', free: 'Бесплатно, без аккаунта',
|
|
941
997
|
siteDetail: (n, viaLinks) => `robots.txt, карты сайта, llms.txt, главная и ${n} ${pagesRu(n)} выборки, прочитанные так же, как их читает любой посетитель${viaLinks ? `; ${viaLinks} из них найдены по ссылкам с главной, потому что в карте сайта их слишком мало` : ''}`,
|
|
@@ -991,7 +1047,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
991
1047
|
reading,
|
|
992
1048
|
/* Сырьё для готовых файлов: профили, которые сайт сам про себя заявляет, и есть ли контакты. */
|
|
993
1049
|
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),
|
|
1050
|
+
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
1051
|
notes,
|
|
996
1052
|
};
|
|
997
1053
|
}
|
|
@@ -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(/ | /g, ' ').replace(/&/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(/«[^»]*»|"[^"]*"|"[^&]*"/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]}
|
|
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) ? '
|
|
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])}, на ${
|
|
126
|
-
'h1-sample': { label: 'Структура заголовков', value: (v) => (/one H1 per page/.test(v) ? 'по одному 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]} из ${
|
|
129
|
-
utility: { label: 'Служебные страницы в карте сайта', value: (v) => `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')}, например ${v.split('such as ')[1] || ''}
|
|
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])}
|
|
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) => (/button that leads/.test(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
|
-
|
|
176
|
-
|
|
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
|
};
|