@operstack/audit 0.35.1 → 0.36.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/collect.mjs +73 -17
- package/src/etalon.mjs +1 -1
- package/src/fix.mjs +1 -1
- package/src/i18n.mjs +36 -23
- package/src/index.mjs +1 -0
- package/src/narrative.mjs +78 -31
- package/src/pagekind.mjs +6 -0
- package/src/presentation.mjs +95 -0
- package/src/prompts.mjs +56 -15
- package/src/render.mjs +41 -25
- package/src/rendered.mjs +8 -3
- package/src/tasks-pdf.mjs +1 -1
- package/src/test-answer-first.mjs +27 -0
- package/src/test-demand.mjs +3 -3
- package/src/test-i18n.mjs +3 -3
- package/src/test-narrative.mjs +1 -1
- package/src/test-overall.mjs +1 -1
- package/src/test-presentation.mjs +67 -0
- package/src/test-rendered.mjs +11 -0
- package/src/test-shopify-store.mjs +1 -1
- package/src/verify.mjs +19 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.2",
|
|
4
4
|
"description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"LICENSE"
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs && node src/test-report-wording.mjs && node src/test-shopify-store.mjs && node src/test-etalon.mjs && node src/test-planted.mjs",
|
|
37
|
+
"test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs && node src/test-report-wording.mjs && node src/test-shopify-store.mjs && node src/test-etalon.mjs && node src/test-planted.mjs && node src/test-presentation.mjs && node src/test-answer-first.mjs",
|
|
38
38
|
"sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
|
|
39
39
|
"verify:live": "node src/verify-live.mjs"
|
|
40
40
|
},
|
package/src/collect.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { dirname, resolve } from 'node:path';
|
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { renderedDom, compareReadings, jsBlindnessCheck } from './rendered.mjs';
|
|
10
10
|
import { parse } from 'node-html-parser';
|
|
11
|
+
import { profileLine } from './i18n.mjs';
|
|
11
12
|
import { localiseChecks } from './i18n.mjs';
|
|
12
13
|
import { checkVisibility, VISIBILITY_DEFAULTS, internalLinks, robotsAllows, linksInText } from './visibility.mjs';
|
|
13
14
|
|
|
@@ -33,7 +34,7 @@ async function get(url, { method = 'GET', timeout = REQUEST_TIMEOUT } = {}) {
|
|
|
33
34
|
const text = method === 'GET' ? await res.text() : '';
|
|
34
35
|
// Заголовок Link нужен проверке «есть ли markdown-версия страницы»: им сайт говорит агенту,
|
|
35
36
|
// где лежит текстовая копия. Возвращаем его сразу, чтобы не ходить за страницей второй раз.
|
|
36
|
-
return { ok: res.ok, status: res.status, location: res.headers.get('location'), type: res.headers.get('content-type') || '', link: res.headers.get('link') || '', text, ttfb, url };
|
|
37
|
+
return { ok: res.ok, status: res.status, location: res.headers.get('location'), type: res.headers.get('content-type') || '', link: res.headers.get('link') || '', poweredBy: res.headers.get('x-powered-by') || '', text, ttfb, url };
|
|
37
38
|
} catch (e) { return { ok: false, status: 0, error: e.name === 'AbortError' ? 'timeout' : e.message, text: '', ttfb: Date.now() - started, url }; }
|
|
38
39
|
finally { clearTimeout(t); }
|
|
39
40
|
}
|
|
@@ -159,6 +160,9 @@ const SOURCE_RE = /(?<!\p{L})(according to|source:|sources:|data from|reported b
|
|
|
159
160
|
* отчёт о магазине на Shopify писал «CMS: не измерено», хотя в коде стоят Shopify.shop и
|
|
160
161
|
* cdn.shopify.com (brainmd.com, 12.10.2026). Только надёжные следы: лучше промолчать, чем угадать.
|
|
161
162
|
*/
|
|
163
|
+
/** Пороги статусов в одном месте (14.10.2026): «Норма» по датам только если датированы хотя бы 3 страницы выборки или половина. */
|
|
164
|
+
export const DATES_OK_MIN = 3;
|
|
165
|
+
|
|
162
166
|
const STACK_SIGNS = [
|
|
163
167
|
['Shopify', /\bShopify\.shop\b|cdn\.shopify\.com|\/cdn\/shop\/|myshopify\.com/i],
|
|
164
168
|
['WordPress', /\/wp-content\/|\/wp-includes\//i],
|
|
@@ -182,7 +186,7 @@ export function detectStack(html) {
|
|
|
182
186
|
return hit ? hit[0] : '';
|
|
183
187
|
}
|
|
184
188
|
|
|
185
|
-
function analysePage(url, html) {
|
|
189
|
+
export function analysePage(url, html) {
|
|
186
190
|
const root = parse(html, { blockTextElements: { script: true, style: true, noscript: true } });
|
|
187
191
|
const attr = (sel, a) => root.querySelector(sel)?.getAttribute(a) ?? '';
|
|
188
192
|
const title = root.querySelector('title')?.text.trim() ?? '';
|
|
@@ -292,7 +296,8 @@ function analysePage(url, html) {
|
|
|
292
296
|
// A honeypot is invisible to a person, so counting it as a field the visitor must fill is wrong.
|
|
293
297
|
const visibleToVisitor = (el) => {
|
|
294
298
|
if (el.getAttribute('tabindex') === '-1') return false;
|
|
295
|
-
|
|
299
|
+
// Невидимая ловушка для спам-ботов (class «inv», «visually-hidden») не поле для посетителя: cimes.ru 14.10.2026 «6 полей» при 4 видимых.
|
|
300
|
+
if (/honeypot|\bhp\b|(^|\s)(inv|invisible|visually-hidden|sr-only|d-none|hidden)(\s|$)/i.test(`${el.getAttribute('class') || ''} ${el.getAttribute('name') || ''}`)) return false;
|
|
296
301
|
for (let n = el.parentNode; n; n = n.parentNode) {
|
|
297
302
|
if (typeof n.getAttribute !== 'function') break;
|
|
298
303
|
if (n.getAttribute('aria-hidden') === 'true') return false;
|
|
@@ -301,7 +306,7 @@ function analysePage(url, html) {
|
|
|
301
306
|
}
|
|
302
307
|
return true;
|
|
303
308
|
};
|
|
304
|
-
const formFields = firstForm ? firstForm.querySelectorAll('input, textarea, select').filter((i) => !/^(hidden|submit|button|image)$/i.test(i.getAttribute('type') || '')).filter(visibleToVisitor).length : 0;
|
|
309
|
+
const formFields = firstForm ? firstForm.querySelectorAll('input, textarea, select').filter((i) => !/^(hidden|submit|button|image|checkbox|radio|reset)$/i.test(i.getAttribute('type') || '')).filter(visibleToVisitor).length : 0;
|
|
305
310
|
const formAt = firstForm ? bodyInner.indexOf(firstForm.outerHTML.slice(0, 120)) : -1;
|
|
306
311
|
const formDepth = firstForm && formAt >= 0 && bodyInner.length ? Math.round((formAt / bodyInner.length) * 100) : null;
|
|
307
312
|
/*
|
|
@@ -344,6 +349,12 @@ function analysePage(url, html) {
|
|
|
344
349
|
email: /"email"\s*:/.test(ld) || mailLinks > 0,
|
|
345
350
|
sameAs: [...new Set(sameAs.map((u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } }).filter(Boolean))],
|
|
346
351
|
sameAsUrls: [...new Set(sameAs)],
|
|
352
|
+
// Карточки на картах и в каталогах, которые сайт показывает виджетом или ссылкой: у cimes.ru в подвале рейтинг
|
|
353
|
+
// Яндекс Карт (sprav/widget/rating-badge/1215897959), а отчёт писал «профилей на площадках нет» (15.10.2026).
|
|
354
|
+
listings: [...new Set([
|
|
355
|
+
...[...String(html).matchAll(/yandex\.(?:ru|com)\/(?:sprav\/widget\/rating-badge|maps\/org(?:\/[^"'\s/]+)?)\/(\d{6,})/gi)].map((m) => `https://yandex.ru/maps/org/${m[1]}/`),
|
|
356
|
+
...[...String(html).matchAll(/2gis\.ru\/[^"'\s]*?\/firm\/(\d{6,})/gi)].map((m) => `https://2gis.ru/firm/${m[1]}`),
|
|
357
|
+
])],
|
|
347
358
|
// Только ссылки внутри своего сайта: «yandex.ru/support/.../about» засчитывался cimes.ru как страница о компании (14.10.2026).
|
|
348
359
|
aboutPage: hrefsLower.some((h) => { let own = false; try { own = !/^[a-z]+:/.test(h) && !h.startsWith('//') ? true : new URL(h, url).host.replace(/^www\./, '') === new URL(url).host.replace(/^www\./, ''); } catch { own = false; } return own && /\/(about|about-us|about-company|our-story|team|company|o-nas|o-kompanii|kompaniya)\/?($|[?#])/.test(h); }),
|
|
349
360
|
policyPages: [...new Set(hrefsLower.flatMap(policyKinds))],
|
|
@@ -352,13 +363,22 @@ function analysePage(url, html) {
|
|
|
352
363
|
// Answer-first family, the same measures the free AI visibility check uses, so the two agree.
|
|
353
364
|
const afterH1 = bodyHtml.split(/<\/h1>/i)[1] || '';
|
|
354
365
|
// Без H1 первый абзац берём из тела страницы: иначе у главной без H1 «текста нет» (14.10.2026, medeq-mc.ru).
|
|
355
|
-
|
|
366
|
+
// Вводный блок после H1 это все абзацы до первого подзаголовка, кнопки или формы. На cimes.ru (15.10.2026) ответ стоял
|
|
367
|
+
// двумя абзацами: описание на 19 слов и сразу «Визитка от 28 000 ₽ · … Смета за 1–2 дня» в p.hero-prices, а движок
|
|
368
|
+
// брал только первый <p> и писал «ответа с цифрой нет». Блок вместе укладывается в 20-90 слов: он и есть первый абзац.
|
|
369
|
+
const leadZone = afterH1.split(/<h[2-6]\b|<button\b|<form\b|<a\b[^>]*class=["'][^"']*\bbtn\b/i)[0];
|
|
370
|
+
const leadParas = [...leadZone.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => plain(m[1])).filter(Boolean);
|
|
371
|
+
const wordsOf = (t) => (String(t).match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
|
|
372
|
+
const leadBlock = leadParas.join(' ');
|
|
373
|
+
const onePara = plain((afterH1.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || ['', ''])[1]);
|
|
374
|
+
const firstPara = (leadParas.length > 1 && wordsOf(onePara) < 20 && wordsOf(leadBlock) <= 90 ? leadBlock : onePara)
|
|
356
375
|
|| ([...bodyHtml.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => plain(m[1])).find((t) => (t.match(/[\p{L}]+/gu) || []).length >= 12) || '');
|
|
357
376
|
// Фраза, где сайт сам говорит, чем занимается: для строки «Чем занимается» при пустом описании (14.10.2026).
|
|
358
377
|
const offerSentence = (String(text || '').match(/[^.!?]{0,200}(?:предлага\p{L}*|оказыва\p{L}*|занимаемся|специализир\p{L}*|we offer|we provide|we specialise|we specialize)[^.!?]{0,200}[.!?]/iu) || [''])[0].trim().slice(0, 260);
|
|
359
378
|
const h2List = [...bodyHtml.matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi)].map((m) => plain(m[1]).trim()).filter(Boolean).slice(0, 10);
|
|
360
379
|
const firstParaWords = (firstPara.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length;
|
|
361
|
-
|
|
380
|
+
// Цифра ответа это число, а не часть слова: «HTML5, CSS3» и «1С» цифрой не считаются (cimes.ru, 15.10.2026).
|
|
381
|
+
const answerFirst = firstParaWords >= 20 && firstParaWords <= 90 && /(?<![\p{L}\d])\d+(?:[\s.,]\d+)*(?![\p{L}\d])/u.test(firstPara);
|
|
362
382
|
const h2Count = (bodyHtml.match(/<h2[\s>]/gi) || []).length;
|
|
363
383
|
const h3Count = (bodyHtml.match(/<h3[\s>]/gi) || []).length;
|
|
364
384
|
const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
|
|
@@ -733,6 +753,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
733
753
|
const reading = { wanted: wanted.length, read: sample.filter((x) => x.title !== undefined).length, stoppedAt, gapMs: gap, crawlDelay: politeness, fromSitemap: wanted.length - 1 - fromLinks, fromLinks };
|
|
734
754
|
const good = sample.filter((p) => p.title !== undefined);
|
|
735
755
|
const hp = homePage || good[0];
|
|
756
|
+
const poweredBy = (() => { const v = String(home.response?.poweredBy || '').trim(); return v && !/^(php|asp\.net|express|next\.js|nuxt|plesk|servlet|jsp|perl|mono|kestrel|hhvm|openresty|nginx|apache|w3 total cache|wp engine)/i.test(v) ? v.slice(0, 60) : ''; })();
|
|
736
757
|
|
|
737
758
|
if (hp) {
|
|
738
759
|
// Ноль в этих трёх строках означает «элемента нет», а не «маленькое значение». Строка
|
|
@@ -795,6 +816,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
795
816
|
checks.push(row('org-schema', 'geo', 'Organization or business schema', good.some((p) => p.schemaTypes.some((t) => /Organization|Corporation|NGO|EducationalOrganization|GovernmentOrganization|MedicalOrganization|LocalBusiness|RealEstateAgent|Dentist|Physician|Hospital|Pharmacy|VeterinaryCare|LegalService|Attorney|Notary|AccountingService|InsuranceAgency|FinancialService|BankOrCreditUnion|AutomotiveBusiness|AutoRepair|AutoDealer|HomeAndConstructionBusiness|Plumber|Electrician|RoofingContractor|HVACBusiness|HousePainter|Locksmith|MovingCompany|GeneralContractor|Restaurant|FoodEstablishment|CafeOrCoffeeShop|Bakery|BarOrPub|LodgingBusiness|Hotel|TravelAgency|HealthAndBeautyBusiness|BeautySalon|HairSalon|DaySpa|NailSalon|TattooParlor|SportsActivityLocation|HealthClub|Gym|ProfessionalService|Store|ClothingStore|GroceryStore|HardwareStore|FurnitureStore|JewelryStore|PetStore|ChildCare|Electrician|EmploymentAgency|SelfStorage|Library|Museum|EntertainmentBusiness|MedicalClinic|DentalClinic|Optician|PhysicalTherapy|NutritionService|EmergencyService|ITService|WebDesignService|MarketingAgency|AdvertisingAgency|SoftwareApplication/.test(t))) ? 'ok' : 'warn', good.some((p) => p.schemaTypes.some((t) => /Organization|Corporation|NGO|EducationalOrganization|GovernmentOrganization|MedicalOrganization|LocalBusiness|RealEstateAgent|Dentist|Physician|Hospital|Pharmacy|VeterinaryCare|LegalService|Attorney|Notary|AccountingService|InsuranceAgency|FinancialService|BankOrCreditUnion|AutomotiveBusiness|AutoRepair|AutoDealer|HomeAndConstructionBusiness|Plumber|Electrician|RoofingContractor|HVACBusiness|HousePainter|Locksmith|MovingCompany|GeneralContractor|Restaurant|FoodEstablishment|CafeOrCoffeeShop|Bakery|BarOrPub|LodgingBusiness|Hotel|TravelAgency|HealthAndBeautyBusiness|BeautySalon|HairSalon|DaySpa|NailSalon|TattooParlor|SportsActivityLocation|HealthClub|Gym|ProfessionalService|Store|ClothingStore|GroceryStore|HardwareStore|FurnitureStore|JewelryStore|PetStore|ChildCare|Electrician|EmploymentAgency|SelfStorage|Library|Museum|EntertainmentBusiness|MedicalClinic|DentalClinic|Optician|PhysicalTherapy|NutritionService|EmergencyService|ITService|WebDesignService|MarketingAgency|AdvertisingAgency|SoftwareApplication/.test(t))) ? 'present' : 'not found: answer engines have no entity to attach the site to'));
|
|
796
817
|
checks.push(row('resources', 'technical', 'Page weight (homepage)', hp.scripts > 30 ? 'bad' : hp.scripts > 15 ? 'warn' : 'ok', `${hp.scripts} script file(s), ${hp.stylesheets} stylesheet(s), ${hp.images} image(s)`));
|
|
797
818
|
if (hp.stack) checks.push(row('cms', 'overview', 'CMS', 'na', hp.generator ? hp.generator : `${hp.stack} (detected from the page code)`));
|
|
819
|
+
// Своя система сайта называет себя в заголовке X-Powered-By: cimes.ru отвечает «Cimes CMS» (14.10.2026). Ссылки вида
|
|
820
|
+
// /bitrix/ на главной у веб-студии это разделы услуг, а не след движка, поэтому по ним не угадываем.
|
|
821
|
+
else if (poweredBy) checks.push(row('cms', 'overview', 'CMS', 'na', `${poweredBy} (from the X-Powered-By server header)`));
|
|
798
822
|
checks.push(row('hreflang', 'technical', 'hreflang', hp.hreflang ? 'ok' : 'na', hp.hreflang ? `${hp.hreflang} alternate(s)` : 'none (single language)'));
|
|
799
823
|
}
|
|
800
824
|
const imgs = good.reduce((a, p) => a + p.images, 0); const noAlt = good.reduce((a, p) => a + p.imagesNoAlt, 0);
|
|
@@ -867,7 +891,17 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
867
891
|
// на каждую страницу, а на странице услуги даты может не быть вовсе. Считаем обе и пишем обе.
|
|
868
892
|
// Видимая дата засчитывается, только если совпадает с датой в разметке: дата лицензии или чужой новости в боковом блоке это не дата страницы.
|
|
869
893
|
const seen = good.filter((p) => p.dates.visibleMatch).length;
|
|
870
|
-
|
|
894
|
+
// Даты нужны статьям, новостям и кейсам. Нет таких страниц в выборке: оценивать нечего, а не «Частично» (cimes.ru, 14.10.2026).
|
|
895
|
+
const ARTICLE = /\/(blog|news|novosti|stati|statya|article|articles|post|posts|journal|zhurnal|media|press|kejsy?|cases?|portfolio|insights|guides?)\/[^/]+/i;
|
|
896
|
+
const NOT_ARTICLE = /\/(contact|contacts|kontakty?|about|o-nas|o-kompanii|uslugi|services?|price|ceny|prajs|policy|politika|soglasie|cart|checkout)(\/|$)/i;
|
|
897
|
+
const articleLike = good.filter((p) => { const path = new URL(p.url).pathname; return ARTICLE.test(path) || (p.schemaTypes || []).some((t) => /^(Article|BlogPosting|NewsArticle|Report|MedicalWebPage)$/.test(t)) || (p.dates?.published && !NOT_ARTICLE.test(path) && path !== '/'); });
|
|
898
|
+
// Виды считаем отдельно и называем только те, что есть: «статьи блога и новости, 271» было 231 статьёй и 40 работами
|
|
899
|
+
// портфолио, а новостей у cimes.ru нет вовсе (15.10.2026).
|
|
900
|
+
const kindCount = (re) => sitemapPages.filter((u) => { try { const p = new URL(u).pathname; return re.test(p) && ARTICLE.test(p); } catch { return false; } }).length;
|
|
901
|
+
const notSampled = { blog: kindCount(/\/(blog|stati|statya|article|articles|post|posts|journal|zhurnal|insights|guides?)\//i), news: kindCount(/\/(news|novosti|press|media)\//i), cases: kindCount(/\/(kejsy?|cases?|portfolio)\//i) };
|
|
902
|
+
const notSampledLine = Object.entries(notSampled).filter(([, n]) => n).map(([k, n]) => `${k} ${n}`).join(', ');
|
|
903
|
+
if (good.length > 2 && !articleLike.length) checks.push(row('dates', 'content', 'Publication dates exposed', 'na', notSampledLine ? `no articles, news or cases in the sample (not sampled from the sitemap: ${notSampledLine}): nothing to date here` : 'no articles, news or cases in the sample: nothing to date'));
|
|
904
|
+
else if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', (dated >= DATES_OK_MIN || dated >= good.length / 2) && seen >= dated / 2 ? 'ok' : 'warn', dated ? `${dated} of ${good.length} sampled pages carry a date in the markup; on ${seen} of them the same date is also visible on the page` : 'no article dates in the sample: answer engines cannot tell what is current'));
|
|
871
905
|
if (/wordpress/i.test(hp?.stack || '')) { const x = await get(`${siteOrigin}/xmlrpc.php`, { method: 'HEAD' }); if (x.status === 405 || x.status === 200) checks.push(row('xmlrpc', 'technical', 'xmlrpc.php', 'warn', `answers ${x.status}`, 'pingback endpoint open: attack surface with no SEO value')); }
|
|
872
906
|
|
|
873
907
|
// ---- Off-page and trust ----------------------------------------------------------------
|
|
@@ -895,7 +929,21 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
895
929
|
checks.push(row('trust-contact', 'offpage', 'Direct contact details', channels.length >= 2 ? 'ok' : channels.length ? 'warn' : 'bad', channels.length ? channels.join(' and ') : 'no phone, email or messenger is published'));
|
|
896
930
|
const ownHost = host.replace(/^www\./, '');
|
|
897
931
|
// Ссылка на чат WhatsApp или Viber это способ написать, а не профиль на площадке (cimes.ru, 14.10.2026).
|
|
898
|
-
|
|
932
|
+
// Telegram в sameAs бывает каналом (профиль, есть подписчики) и чатом с менеджером (мессенджер, «Send Message»).
|
|
933
|
+
// По адресу не различить: открываем страницу t.me, это бесплатно. cimes.ru (15.10.2026): t.me/CimesWebDevelopment
|
|
934
|
+
// это чат, и «указан 1 профиль: Telegram» был неправдой; у neronov.pro t.me это канал на 322 подписчика.
|
|
935
|
+
const MESSENGER_HOST = /^(wa\.me|api\.whatsapp\.com|whatsapp\.com|chat\.whatsapp\.com|viber\.com|invite\.viber\.com)$/i;
|
|
936
|
+
const tgUrls = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].filter((u) => /^https?:\/\/(www\.)?(t\.me|telegram\.me)\/[^/?#]+/i.test(u)).slice(0, 3);
|
|
937
|
+
const tgKind = {};
|
|
938
|
+
for (const u of tgUrls) {
|
|
939
|
+
const r = await get(u);
|
|
940
|
+
const b = String(r.text || '');
|
|
941
|
+
tgKind[u] = /tgme_page_extra">\s*[\d\s.,KkMmКкМм]+\s*(subscribers|members|подписчик|участник)/i.test(b) ? 'channel' : r.ok && /tgme_action_button|Send Message|Написать/i.test(b) ? 'chat' : 'unknown';
|
|
942
|
+
}
|
|
943
|
+
const tgChatOnly = tgUrls.length > 0 && tgUrls.every((u) => tgKind[u] === 'chat');
|
|
944
|
+
const sameAsHosts = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAs || []))].filter((h) => h !== ownHost && !h.endsWith(`.${ownHost}`));
|
|
945
|
+
const messengersInSchema = sameAsHosts.filter((h) => MESSENGER_HOST.test(h) || (tgChatOnly && /^(t\.me|telegram\.me)$/i.test(h)));
|
|
946
|
+
const profiles = sameAsHosts.filter((h) => !messengersInSchema.includes(h));
|
|
899
947
|
/*
|
|
900
948
|
* Для готовой разметки нужен адрес профиля, а не домен: «https://x.com» это не страница компании,
|
|
901
949
|
* а сам сайт. И список сужается до площадок, где профиль вообще бывает: сайт нередко перечисляет
|
|
@@ -906,9 +954,15 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
906
954
|
const profileUrls = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].filter((u) => {
|
|
907
955
|
try { const x = new URL(u); return PROFILE_HOST.test(x.host.replace(/^www\./, '')) && x.pathname.replace(/\/+$/, '').length > 1; } catch { return false; }
|
|
908
956
|
});
|
|
909
|
-
|
|
957
|
+
const sameAsAll = [...new Set(pagesForTrust.flatMap((p) => p.trust?.sameAsUrls || []))].join(' ');
|
|
958
|
+
const listingsMissing = [...new Set(pagesForTrust.flatMap((p) => p.trust?.listings || []))].filter((u) => { const id = (u.match(/(\d{6,})/) || [])[1]; return id && !sameAsAll.includes(id); }).slice(0, 3);
|
|
959
|
+
const messengerNames = [...new Set(messengersInSchema.map((h) => (/t\.me|telegram/i.test(h) ? 'Telegram' : /whatsapp|wa\.me/i.test(h) ? 'WhatsApp' : /viber/i.test(h) ? 'Viber' : h)))];
|
|
960
|
+
checks.push(row('trust-profiles', 'offpage', 'Profiles the site claims elsewhere', profiles.length >= 2 ? 'ok' : profiles.length ? 'warn' : 'bad', profiles.length ? profileLine(profiles.slice(0, 5), lang, profiles.length) : listingsMissing.length ? `${messengerNames.length ? `only messengers in sameAs (${messengerNames.join(', ')}); ` : ''}a listing shown on the site is missing from the markup: ${listingsMissing.join(', ')}` : messengerNames.length ? `only messengers in sameAs (${messengerNames.join(', ')}): no profile on any platform` : 'no sameAs links: the site claims no profile anywhere else', profiles.length ? '' : 'a free off-site signal: links to your own profiles in the markup'));
|
|
961
|
+
if (!profiles.length && messengerNames.length) checks[checks.length - 1].messengersOnly = messengerNames;
|
|
962
|
+
if (!profiles.length && listingsMissing.length) { checks[checks.length - 1].listings = listingsMissing; checks[checks.length - 1].status = 'warn'; }
|
|
910
963
|
const about = anyTrust((p) => p.trust?.aboutPage);
|
|
911
|
-
|
|
964
|
+
// Страницы о компании нет: это «Проблема», а не «Частично» (cimes.ru, 14.10.2026).
|
|
965
|
+
checks.push(row('trust-about', 'offpage', 'A page that says who is behind the site', about ? 'ok' : 'bad', about ? 'linked from the sampled pages' : 'no about or team page is linked'));
|
|
912
966
|
const policies = [...new Set(pagesForTrust.flatMap((p) => p.trust?.policyPages || []))];
|
|
913
967
|
checks.push(row('trust-policies', 'offpage', 'Privacy and terms', policies.length >= 2 ? 'ok' : policies.length ? 'warn' : 'bad', policies.length ? policies.join(', ') : 'neither a privacy policy nor terms are linked'));
|
|
914
968
|
|
|
@@ -935,8 +989,9 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
935
989
|
const viaMsg = pagesForTrust.filter((p) => p.messengerLinks).length;
|
|
936
990
|
checks.push(row('conv-form-depth', 'conversion', 'How far down the form sits', withForm.length || viaMsg ? 'na' : 'bad', withForm.length ? 'position could not be measured' : viaMsg ? `no form; requests go through messenger links on ${viaMsg} of ${pagesForTrust.length} sampled page(s)` : 'no form on the sampled pages'));
|
|
937
991
|
}
|
|
938
|
-
|
|
939
|
-
|
|
992
|
+
// Поля считаем по тем же формам заявки, что и глубину: иначе «форма на одной странице» и «медиана по семи формам» спорили (cimes.ru, 15.10.2026).
|
|
993
|
+
const fields = withForm.filter((p) => p.formDepth !== null && p.formDepth !== undefined).map((p) => p.formFields).filter((n) => n > 0);
|
|
994
|
+
if (fields.length) { const mf = median(fields); checks.push(row('conv-form-fields', 'conversion', 'What the form asks for', mf <= 4 ? 'ok' : mf <= 6 ? 'warn' : 'bad', (fields.length === 1 ? `${mf} field(s) in the only enquiry form found in the sample` : `median ${mf} field(s) before a visitor can send anything`), mf > 4 ? 'every extra field costs replies, and a phone number costs the most' : '')); }
|
|
940
995
|
const msg = pagesForTrust.filter((p) => p.messengerLinks > 0);
|
|
941
996
|
checks.push(row('conv-messenger', 'conversion', 'A messenger link', msg.length ? 'ok' : 'warn', msg.length ? `on ${msg.length} of ${pagesForTrust.length} sampled page(s)` : 'none on the sampled pages', msg.length ? '' : 'for an international audience reading on a phone, one messenger link is usually worth more than a form'));
|
|
942
997
|
|
|
@@ -1017,14 +1072,14 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
1017
1072
|
const ru = lang === 'ru';
|
|
1018
1073
|
const pagesRu = (n) => (n % 10 === 1 && n % 100 !== 11 ? 'страница' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'страницы' : 'страниц');
|
|
1019
1074
|
const S = ru ? {
|
|
1020
|
-
rows: ['
|
|
1075
|
+
rows: ['Система управления сайтом (CMS)', 'Страниц в карте сайта', 'Язык', 'Чем занимается'],
|
|
1021
1076
|
partial: (n, read, total) => `не меньше ${n}: прочитано ${read} из ${total} файлов карты, остальные не успели`,
|
|
1022
1077
|
site: 'Сам сайт', free: 'Бесплатно, без аккаунта',
|
|
1023
1078
|
siteDetail: (n, viaLinks) => `robots.txt, карты сайта, llms.txt, главная и ${n} ${pagesRu(n)} выборки, прочитанные так же, как их читает любой посетитель${viaLinks ? `; ${viaLinks} из них найдены по ссылкам с главной, потому что в карте сайта их слишком мало` : ''}`,
|
|
1024
1079
|
paid: 'Платный сервис, по желанию', paidDetail: 'только для справки, ни в одну оценку не входит',
|
|
1025
1080
|
limitations: ['Google Search Console и Яндекс Вебмастер: показов, кликов и запросов нет', 'Аналитика: данных о посещениях и поведении нет', 'Сервисы ключевых слов и ссылок: в проверке по публичным сигналам не используются'],
|
|
1026
1081
|
fix: 'Исправить',
|
|
1027
|
-
roadmap: [['Неделя 1', '
|
|
1082
|
+
roadmap: [['Неделя 1', 'Три главных приоритета'], ['Недели 2-4', 'Основа'], ['Месяцы 2-3', 'Проверка результата']],
|
|
1028
1083
|
geo: ['Понятно ли, кто вы', 'Упоминания на других сайтах', 'Отзывы и пресса'],
|
|
1029
1084
|
conv: ['Форма связи', 'Цена видна', 'Мессенджеры'],
|
|
1030
1085
|
} : {
|
|
@@ -1035,7 +1090,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
1035
1090
|
paid: 'Paid tool, optional', paidDetail: 'reported for context only, not counted in any score',
|
|
1036
1091
|
limitations: ['Google Search Console: no impressions, clicks or query data', 'Analytics: no traffic or behaviour data', 'Keyword and backlink tools: not used in the external tier'],
|
|
1037
1092
|
fix: 'Fix',
|
|
1038
|
-
roadmap: [['Week 1', '
|
|
1093
|
+
roadmap: [['Week 1', 'The three main priorities'], ['Weeks 2 to 4', 'Foundation'], ['Months 2 to 3', 'Check the result']],
|
|
1039
1094
|
geo: ['Entity clarity', 'Third-party mentions', 'Reviews and PR'],
|
|
1040
1095
|
conv: ['Contact form', 'Price visible', 'Messengers'],
|
|
1041
1096
|
};
|
|
@@ -1049,7 +1104,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
|
|
|
1049
1104
|
scores,
|
|
1050
1105
|
scoreBasis,
|
|
1051
1106
|
summary: { lead: '{{Three sentences: what the site is, what works, what holds it back.}}', verdict: '{{Key takeaway in three sentences, ending with how fast the critical issues can be fixed.}}', priorities: ['{{Priority one}}', '{{Priority two}}', '{{Priority three}}'] },
|
|
1052
|
-
overview: { rows: [[S.rows[0], hp?.generator || hp?.stack || '{{stack}}'], [S.rows[1], sitemapRead.stopped ? S.partial(sitemapPages.length, sitemapRead.filesRead, sitemapRead.filesRead + sitemapRead.pending) : String(sitemapPages.length)], [S.rows[2], '{{language}}'], [S.rows[3], '{{products}}']], note: '{{One paragraph on how the offer is structured on the site and whether it is clear.}}' },
|
|
1107
|
+
overview: { rows: [[S.rows[0], hp?.generator || hp?.stack || poweredBy || '{{stack}}'], [S.rows[1], sitemapRead.stopped ? S.partial(sitemapPages.length, sitemapRead.filesRead, sitemapRead.filesRead + sitemapRead.pending) : String(sitemapPages.length)], [S.rows[2], '{{language}}'], [S.rows[3], '{{products}}']], note: '{{One paragraph on how the offer is structured on the site and whether it is clear.}}' },
|
|
1053
1108
|
critical: criticalShown,
|
|
1054
1109
|
checks: localised,
|
|
1055
1110
|
content: { strengths: ['{{strength}}'], weaknesses: ['{{weakness}}'], gaps: ['{{missing landing page or cluster}}'] },
|
|
@@ -1106,7 +1161,8 @@ async function semrushBacklinks(host, log = () => {}, supplied = null) {
|
|
|
1106
1161
|
return { ok: true, source: `Semrush Backlink Analytics, ${new Date().toISOString().slice(0, 10)}`, ascore: n('ascore'), total: n('total'), domains: n('domains_num'), follows: n('follows_num'), nofollows: n('nofollows_num') };
|
|
1107
1162
|
}
|
|
1108
1163
|
|
|
1109
|
-
|
|
1164
|
+
// При чётном числе значений середина это среднее двух средних: «1618» при середине 1459 и 1618 было неверно (cimes.ru, 14.10.2026).
|
|
1165
|
+
function median(a) { const s = [...a].sort((x, y) => x - y); if (!s.length) return 0; const m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2); }
|
|
1110
1166
|
|
|
1111
1167
|
/**
|
|
1112
1168
|
* The six areas of the report. `groups` lists the check groups that feed the score.
|
package/src/etalon.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export function recorder(realFetch = globalThis.fetch) {
|
|
|
17
17
|
const fetchRec = async (input, init = {}) => {
|
|
18
18
|
const r = await realFetch(input, init);
|
|
19
19
|
const body = await r.text();
|
|
20
|
-
const headers = {}; for (const h of ['content-type', 'location', 'link', 'x-robots-tag']) { const v = r.headers.get(h); if (v) headers[h] = v; }
|
|
20
|
+
const headers = {}; for (const h of ['content-type', 'location', 'link', 'x-robots-tag', 'x-powered-by']) { const v = r.headers.get(h); if (v) headers[h] = v; }
|
|
21
21
|
store[keyOf(input, init)] = { status: r.status, url: r.url, headers, body };
|
|
22
22
|
return new Response([101, 204, 205, 304].includes(r.status) ? null : body, { status: r.status, headers });
|
|
23
23
|
};
|
package/src/fix.mjs
CHANGED
|
@@ -53,7 +53,7 @@ export const FIX_ACTIONS = {
|
|
|
53
53
|
dates: { scope: 'always', action: { ru: 'Вывести дату публикации и обновления в разметку страниц', en: 'Expose published and updated dates in the page markup' } },
|
|
54
54
|
'trust-entity': { scope: 'always', action: { ru: 'Добавить город и страну в разметку организации', en: 'Add the city and country to the Organization markup' } },
|
|
55
55
|
'trust-contact': { scope: 'always', action: { ru: 'Опубликовать прямые контакты и вывести их в разметку', en: 'Publish direct contacts and expose them in the markup' } },
|
|
56
|
-
'trust-profiles': { scope: 'always', action: { ru: '
|
|
56
|
+
'trust-profiles': { scope: 'always', action: { ru: 'Указать в разметке профили, которые у компании действительно есть', en: 'Declare in the markup the profiles you actually have' } },
|
|
57
57
|
'trust-about': { scope: 'always', action: { ru: 'Сделать страницу о компании доступной со всех страниц', en: 'Make the about page reachable from every page' } },
|
|
58
58
|
'trust-policies': { scope: 'always', action: { ru: 'Добавить политику конфиденциальности и условия и сослаться на них из подвала', en: 'Add a privacy policy and terms and link them from the footer' } },
|
|
59
59
|
'conv-contact-path': { scope: 'always', action: { ru: 'Дать способ связаться с любой страницы', en: 'Give a way to get in touch from any page' } },
|
package/src/i18n.mjs
CHANGED
|
@@ -21,9 +21,22 @@ const plural = (n, one, few, many) => {
|
|
|
21
21
|
};
|
|
22
22
|
/** Проценты по-русски склоняются: 31 процент, 32 процента, 35 процентов. */
|
|
23
23
|
const pct = (n) => plural(Number(n), 'процент', 'процента', 'процентов');
|
|
24
|
+
// «1: t.me» владелец не читает: называем площадку по-человечески (cimes.ru, 14.10.2026).
|
|
25
|
+
const PROFILE_NAMES = { 't.me': 'Telegram', 'telegram.me': 'Telegram', 'vk.com': 'ВКонтакте', 'ok.ru': 'Одноклассники', 'dzen.ru': 'Дзен', 'rutube.ru': 'Rutube', 'youtube.com': 'YouTube', 'instagram.com': 'Instagram', 'facebook.com': 'Facebook', 'linkedin.com': 'LinkedIn', 'x.com': 'X', 'twitter.com': 'X', 'tiktok.com': 'TikTok', 'pinterest.com': 'Pinterest', 'github.com': 'GitHub', 'behance.net': 'Behance', 'prodoctorov.ru': 'ПроДокторов', '2gis.ru': '2ГИС', 'zoon.ru': 'Zoon', 'yandex.ru': 'Яндекс', 'avito.ru': 'Авито', 'profi.ru': 'Профи' };
|
|
26
|
+
const profileName = (h) => PROFILE_NAMES[String(h).trim().replace(/^www\./, '')] || String(h).trim();
|
|
27
|
+
/** Строка профилей для отчёта: «указан 1 профиль: Telegram», «2 profiles: Telegram, VK». Голые «1: t.me» владелец не читает. */
|
|
28
|
+
export function profileLine(hosts, lang = 'en', total = hosts.length) {
|
|
29
|
+
const names = [...new Set(hosts.map(profileName))].join(', ');
|
|
30
|
+
if (lang === 'ru') return `${total === 1 ? 'указан' : 'указано'} ${plural(total, 'профиль', 'профиля', 'профилей')}: ${names}`;
|
|
31
|
+
return `${total} profile${total === 1 ? '' : 's'}: ${names}`;
|
|
32
|
+
}
|
|
24
33
|
const pages = (n) => plural(Number(n), 'страница', 'страницы', 'страниц');
|
|
25
34
|
const pagesOf = (a, b) => `${a} из ${pages(b)}`;
|
|
26
35
|
/** «из 21 страницы», «из 20 страниц»: после «из» родительный падеж. */
|
|
36
|
+
// «1 из 20 страниц указывает»: глагол по числу страниц, где это есть (cimes.ru, 14.10.2026).
|
|
37
|
+
const vb = (n, one, many) => (Number(n) % 10 === 1 && Number(n) % 100 !== 11 ? one : many);
|
|
38
|
+
// «есть на 7 страницах», «на 1 странице»: предложный падеж после «на» (cimes.ru, 14.10.2026).
|
|
39
|
+
const pagesLoc = (n) => (Number(n) % 10 === 1 && Number(n) % 100 !== 11 ? 'странице' : 'страницах');
|
|
27
40
|
const pagesGen = (n) => (Number(n) % 10 === 1 && Number(n) % 100 !== 11 ? 'страницы' : 'страниц');
|
|
28
41
|
|
|
29
42
|
/** Перевод «сколько проверок пройдено» из подписи под оценкой. */
|
|
@@ -35,7 +48,7 @@ export function localiseBasisNote(note) {
|
|
|
35
48
|
}
|
|
36
49
|
const [, ok, total, warn, bad] = m;
|
|
37
50
|
const parts = [`${ok} из ${plural(Number(total), 'проверки', 'проверок', 'проверок')} пройдено`];
|
|
38
|
-
if (warn) parts.push(
|
|
51
|
+
if (warn) parts.push(`${Number(warn)} частично`);
|
|
39
52
|
if (bad) parts.push(plural(Number(bad), 'провалена', 'провалены', 'провалено'));
|
|
40
53
|
return parts.join(', ');
|
|
41
54
|
}
|
|
@@ -99,15 +112,15 @@ const RU = {
|
|
|
99
112
|
canonical: { label: 'Канонический адрес', value: (v) => (/missing|none/i.test(v) ? 'не указан' : /of \d+ sampled/.test(v) ? `указан на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки` : 'указан'), comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
100
113
|
// Добавлено 17.09.2026 вместе с новыми проверками. Русский отчёт платный, английские строки в нём
|
|
101
114
|
// это такой же брак, как русский отчёт англоязычному покупателю.
|
|
102
|
-
'title-all': { label: 'Заголовок вкладки на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]}
|
|
103
|
-
'description-all': { label: 'Описание на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]}
|
|
104
|
-
'schema-all': { label: 'Разметка на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]}
|
|
105
|
-
'og-title': { label: 'og:title на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]}
|
|
106
|
-
'og-image': { label: 'og:image на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]}
|
|
115
|
+
'title-all': { label: 'Заголовок вкладки на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
116
|
+
'description-all': { label: 'Описание на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
117
|
+
'schema-all': { label: 'Разметка на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
118
|
+
'og-title': { label: 'og:title на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (c ? 'без него при пересылке ссылки видно голый адрес или чужой заголовок' : '') },
|
|
119
|
+
'og-image': { label: 'og:image на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки`, comment: (c) => (c ? 'без og:image мессенджеры и соцсети показывают ссылку без картинки' : '') },
|
|
107
120
|
'twitter-card': { label: 'Карточка Twitter', value: (v) => (/no twitter/.test(v) ? 'не задана' : v.replace('twitter:card', 'тип')), comment: (c) => (c ? 'на неё опираются X и часть читалок, когда Open Graph неполный' : '') },
|
|
108
|
-
'breadcrumbs-schema': { label: 'Разметка хлебных крошек', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${
|
|
109
|
-
'howto-schema': { label: 'Разметка пошаговых инструкций', value: (v) => (/no step-by-step/.test(v) ? 'пошаговых страниц в выборке нет' : `есть на ${
|
|
110
|
-
'product-schema': { label: 'Разметка товара', value: (v) => (/no product/.test(v) ? 'страниц товара в выборке нет' : `есть на ${
|
|
121
|
+
'breadcrumbs-schema': { label: 'Разметка хлебных крошек', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`), comment: (c) => (c ? 'крошки говорят системе, где страница стоит, и показываются в ответе рядом со ссылкой' : '') },
|
|
122
|
+
'howto-schema': { label: 'Разметка пошаговых инструкций', value: (v) => (/no step-by-step/.test(v) ? 'пошаговых страниц в выборке нет' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`), comment: (c) => (c ? 'ставить только там, где страница правда ведёт по шагам: разметить ею что-то другое значит соврать системе' : '') },
|
|
123
|
+
'product-schema': { label: 'Разметка товара', value: (v) => (/no product/.test(v) ? 'страниц товара в выборке нет' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`), comment: (c) => (c ? 'только для страниц, которые правда продают названную вещь с ценой' : '') },
|
|
111
124
|
'redirect-chain': { label: 'Переадресации до главной', value: (v) => (/without a redirect/.test(v) ? 'адрес отвечает сразу, без переадресаций' : `${plural(Number(nums(v)[0]), 'переадресация', 'переадресации', 'переадресаций')} до ответа`), comment: (c) => (c ? 'направьте первый адрес сразу на конечный: каждый прыжок стоит времени и веса ссылки' : '') },
|
|
112
125
|
favicon: { label: 'Значок сайта', value: (v) => (/declared/.test(v) ? 'объявлен в шапке страницы' : /answers 200/.test(v) ? '/favicon.ico отвечает' : 'не объявлен, и /favicon.ico не отвечает'), comment: (c) => (c ? 'вкладка, закладка и карточка при пересылке ссылки останутся с пустым квадратом' : '') },
|
|
113
126
|
'engine-chatgpt': { label: 'ChatGPT может читать сайт', value: (v) => (/blocked/.test(v) ? `закрыт в robots.txt: ${v.replace(/^blocked in robots.txt:\s*/, '')}` : 'открыт'), comment: (c) => (c ? 'ChatGPT не заберёт страницу, когда она понадобится для ответа, и не процитирует вас даже там, где вы лучший ответ' : '') },
|
|
@@ -119,26 +132,26 @@ const RU = {
|
|
|
119
132
|
viewport: { label: 'Мобильный viewport', value: (v) => (/missing/.test(v) ? 'не задан' : v), comment: (c) => (c ? 'запрещает масштабирование: плохо для доступности и мобильного поиска' : '') },
|
|
120
133
|
og: { label: 'Карточки для соцсетей', value: (v) => (/none/.test(v) ? 'нет' : v), comment: (c) => (/shortcode/.test(c) ? 'в описании служебный код вместо текста' : '') },
|
|
121
134
|
schema: { label: 'Разметка на главной', value: (v) => (/no JSON-LD/.test(v) ? 'разметки нет' : /invalid/.test(v) ? 'разметка есть, но с ошибкой' : v) },
|
|
122
|
-
'faq-schema': { label: 'Разметка вопросов FAQPage', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${
|
|
135
|
+
'faq-schema': { label: 'Разметка вопросов FAQPage', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${nums(v)[0]} ${pagesLoc(nums(v)[0])} выборки`) },
|
|
123
136
|
'org-schema': { label: 'Разметка организации', value: (v) => (/not found/.test(v) ? 'не найдена: ответным системам не к чему привязать сайт' : 'есть') },
|
|
124
|
-
resources: { label: '
|
|
137
|
+
resources: { label: 'Файлы главной страницы', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'файл скриптов', 'файла скриптов', 'файлов скриптов')}, ${plural(Number(n[1]), 'стиль', 'стиля', 'стилей')}, ${plural(Number(n[2]), 'изображение', 'изображения', 'изображений')}`; } },
|
|
125
138
|
hreflang: { label: 'hreflang', value: (v) => (/none/.test(v) ? 'нет, сайт одноязычный' : `${plural(Number(nums(v)[0]), 'альтернатива', 'альтернативы', 'альтернатив')}`) },
|
|
126
|
-
cms: { label: '
|
|
139
|
+
cms: { label: 'Система управления сайтом (CMS)', value: (v) => v.replace(' (detected from the page code)', ' (определён по коду страницы)').replace(' (from the X-Powered-By server header)', ' (по заголовку сервера X-Powered-By)') },
|
|
127
140
|
xmlrpc: { label: 'xmlrpc.php', value: (v) => `отвечает ${nums(v)[0]}`, comment: () => 'открытая точка для атак, пользы для поиска никакой' },
|
|
128
141
|
alt: { label: 'Подписи к картинкам', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} картинок имеют подпись, это ${pct(n[2])}, на ${n[3]} ${Number(n[3]) % 10 === 1 && Number(n[3]) % 100 !== 11 ? 'странице' : 'страницах'} выборки`; } },
|
|
129
142
|
'h1-sample': { label: 'Структура заголовков', value: (v) => (/one H1 (?:per page|on every sampled page)/.test(v) ? 'по одному H1 на каждой странице выборки' : v.split('; ').map((part) => { const n = nums(part); return /no H1/.test(part) ? `H1 нет на ${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки` : `больше одного H1 на ${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки`; }).join('; ')) },
|
|
130
143
|
'dup-titles': { label: 'Повторы заголовков', value: (v) => (/its own title/.test(v) ? 'у каждой проверенной страницы свой заголовок' : `${plural(Number(nums(v)[0]), 'заголовок повторяется', 'заголовка повторяются', 'заголовков повторяются')} на разных страницах`) },
|
|
131
|
-
thin: { label: '
|
|
144
|
+
thin: { label: 'Объём текста', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки короче 300 слов`) },
|
|
132
145
|
utility: { label: 'Служебные страницы в карте сайта', value: (v) => (/^1 URL: /.test(v) ? `1 адрес: ${v.slice(7)}` : `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')}, например ${v.split('such as ')[1] || ''}`), comment: (c) => { const map = { 'author pages': 'страницы авторов', 'tag pages': 'страницы тегов', 'cart and checkout pages': 'корзина и оформление заказа', 'wishlist pages': 'избранное', 'account pages': 'личный кабинет', feeds: 'ленты RSS', 'utility pages': 'служебные страницы' }; const k = String(c).replace(/ add noise to the index$/, '').split(', ').map((x) => map[x] || x); return `${k.join(', ')}: в индексе не нужны и засоряют его`; } },
|
|
133
146
|
'answer-first': {
|
|
134
147
|
label: 'Ответ в первом абзаце',
|
|
135
|
-
value: (v) => { const n = nums(v);
|
|
148
|
+
value: (v) => { const n = nums(v); const miss = Number(n[1]) - Number(n[0]); return miss > 0 ? `прямого ответа с цифрой в первом абзаце (20-90 слов сразу после H1) нет на ${miss} из ${n[1]} ${pagesGen(n[1])} выборки` : `прямой ответ с цифрой в первом абзаце есть на всех ${n[1]} страницах выборки`; },
|
|
136
149
|
comment: (c) => (c ? 'именно этот абзац ответная система забирает в цитату' : ''),
|
|
137
150
|
},
|
|
138
|
-
sections: { label: 'Разбивка на разделы', value: (v) => { const n = nums(v);
|
|
151
|
+
sections: { label: 'Разбивка на разделы', value: (v) => { const n = nums(v); const few = Number(n[1]) - Number(n[0]); return few > 0 ? `меньше трёх подзаголовков H2 на ${few} из ${n[1]} ${pagesGen(n[1])} выборки` : `три и более подзаголовка H2 на всех ${n[1]} страницах выборки`; }, comment: (c) => (c ? 'системы цитируют разделы, а не сплошной текст' : '') },
|
|
139
152
|
tables: { label: 'Таблицы в тексте', value: (v) => (/no table/.test(v) ? 'на проверенных страницах таблиц нет' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки используют таблицу`), comment: (c) => (c ? 'таблица это второй по частоте формат цитирования после первого абзаца' : '') },
|
|
140
|
-
sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); if (n.length < 2) return /no sampled page carries a figure/.test(v) ? 'на страницах выборки нет цифр, которым нужен источник' : 'не измеряли'; return `${n[0]} из ${pages(n[1])} выборки, где есть
|
|
141
|
-
dates: { label: 'Открытые даты публикации', value: (v) => (/no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : /in the markup/.test(v) ? `${nums(v)[0]} из ${
|
|
153
|
+
sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); if (n.length < 2) return /no sampled page carries a figure/.test(v) ? 'на страницах выборки нет цифр, которым нужен источник' : 'не измеряли'; return `${n[0]} из ${pages(n[1])} выборки, где есть цифры статистики (доли, количества, сравнения), называют, откуда взята цифра, словами или ссылкой рядом с ней`; }, comment: (c) => (c ? 'цифра без источника это первое, что система выбрасывает' : '') },
|
|
154
|
+
dates: { label: 'Открытые даты публикации', value: (v) => (/not sampled from the sitemap/.test(v) ? (() => { const n = (k) => Number((v.match(new RegExp(`${k} (\\d+)`)) || [])[1] || 0); const parts = [n('blog') && `статьи блога (${n('blog')})`, n('news') && `новости (${n('news')})`, n('cases') && `работы из портфолио (${n('cases')})`].filter(Boolean); const list = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} и ${parts[parts.length - 1]}` : parts[0]; return `${list} в выборку не вошли, на страницах выборки датировать нечего`; })() : /nothing to date/.test(v) ? 'статей, новостей и кейсов в выборке нет: датировать нечего' : /precede published/.test(v) ? `дата изменения стоит раньше даты публикации на ${nums(v)[0]} ${plural(Number(nums(v)[0]), 'статье', 'статьях', 'статьях').replace(/^\d+ /, '')}` : /no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : /in the markup/.test(v) ? `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки ${vb(nums(v)[0], 'указывает', 'указывают')} дату в разметке${nums(v)[2] === undefined ? '' : Number(nums(v)[0]) === 1 ? (Number(nums(v)[2]) ? ', и на этой странице та же дата видна в тексте' : ', но в тексте этой страницы даты не видно') : `; на ${nums(v)[2]} из них та же дата видна и в тексте`}` : `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки ${vb(nums(v)[0], 'показывает', 'показывают')} дату`) },
|
|
142
155
|
'llms-health': { label: 'Страницы из llms.txt', value: (v) => (/^all /.test(v) ? `все ${nums(v)[0]} проверенных страниц отвечают и открыты для индексации` : v.replace(/(\d+) dead/, '$1 мёртвых').replace(/(\d+) redirecting/, '$1 с переадресацией').replace(/(\d+) closed to indexing/, '$1 закрытых от индексации').replace(/ of (\d+) checked: /, ' из $1 проверенных: ')), comment: (c) => (c ? 'ИИ читает этот файл вместо обхода сайта: каждая мёртвая или закрытая запись это страница, которую он процитирует неверно или не процитирует вовсе' : '') },
|
|
143
156
|
'agent-card': { label: 'Карточка агента (/.well-known/agent.json)', value: (v) => (/valid JSON/.test(v) ? 'есть, и это правильный JSON' : /not JSON/.test(v) ? 'есть, но это не JSON' : 'нет'), comment: (c) => (c ? 'ранний и необязательный стандарт: файл описывает программного ИИ-агента, если он у компании есть; без своего агента он не нужен' : '') },
|
|
144
157
|
'api-catalog': { label: 'Каталог API (RFC 9727)', value: (v) => (/^present/.test(v) ? 'есть' : 'нет'), comment: (c) => (c ? 'необязательно, и нужно только если у бизнеса действительно есть API, который агент мог бы вызвать' : '') },
|
|
@@ -148,15 +161,15 @@ const RU = {
|
|
|
148
161
|
'trust-contact': {
|
|
149
162
|
label: 'Прямые контакты',
|
|
150
163
|
value: (v) => (/no phone, email or messenger/.test(v) ? 'ни телефона, ни почты, ни мессенджера не опубликовано'
|
|
151
|
-
: v.replace(/\bphone\b/g, 'телефон').replace(/\bemail\b/g, 'почта').replace(/a messenger/g, 'мессенджер').replace(
|
|
164
|
+
: v.replace(/\bphone\b/g, 'телефон').replace(/\bemail\b/g, 'почта').replace(/a messenger/g, 'мессенджер').replace(/, | and /g, ', ').replace(/, ([^,]+)$/, ' и $1')),
|
|
152
165
|
},
|
|
153
|
-
'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' :
|
|
166
|
+
'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/listing shown on the site is missing/.test(v) ? (() => { const urls = v.split('markup: ')[1] || ''; const where = /yandex/.test(urls) ? 'в Яндекс Картах' : /2gis/.test(urls) ? 'в 2ГИС' : 'на картах'; const m = (v.match(/only messengers in sameAs \(([^)]*)\)/) || [])[1]; return `${m ? `в разметке (sameAs) указаны только мессенджеры (${m}); ` : ''}ваша карточка ${where} на сайте показана, но в разметке не указана: ${urls}`; })() : /only messengers in sameAs/.test(v) ? `в разметке указаны только мессенджеры (${(v.match(/\(([^)]*)\)/) || ['', ''])[1]}), профилей на площадках нет` : /no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' : /^\d+ profiles?:/.test(v) ? (() => { const n = Number(nums(v)[0]); return `${n === 1 ? 'указан' : 'указано'} ${plural(n, 'профиль', 'профиля', 'профилей')}: ${v.split(': ').slice(1).join(': ')}`; })() : /^\d+:/.test(v) ? (() => { const n = Number(nums(v)[0]); const names = v.split(': ').slice(1).join(': ').split(/,\s*/).map(profileName).join(', '); return `${n === 1 ? 'указан' : 'указано'} ${plural(n, 'профиль', 'профиля', 'профилей')}: ${names}`; })() : v), comment: (c) => (c ? 'ссылки на настоящие профили компании в разметке помогают поиску и ИИ убедиться, что компания существует, и это ничего не стоит' : '') },
|
|
154
167
|
'trust-about': { label: 'Страница о компании', value: (v) => (/no about/.test(v) ? 'страницы о компании нет или на неё не ведут ссылки' : 'на неё ведут ссылки с проверенных страниц') },
|
|
155
168
|
'trust-policies': { label: 'Политика и условия', value: (v) => (/neither/.test(v) ? 'ни политики конфиденциальности, ни условий на сайте нет' : `есть: ${v.replace(/privacy/g, 'политика').replace(/terms/g, 'условия').replace(/legal/g, 'правовая страница').replace(/cookie/g, 'куки').replace(/disclaimer/g, 'оговорка').replace(/consent/g, 'согласие на обработку данных')}`) },
|
|
156
|
-
'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${
|
|
157
|
-
'conv-cta': { label: 'Призыв к действию
|
|
158
|
-
'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/pop-up/.test(v) ? `форма открывается во всплывающем окне по кнопке на ${nums(v)[0]} из ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'страницы' : 'страниц'} выборки; кнопка на первом экране на ${nums(v)[2]}` : /messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки)` : /no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${pct(nums(v)[0])} вниз по
|
|
159
|
-
'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до
|
|
169
|
+
'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'даёт', 'дают')} форму, контактную ссылку или страницу контактов`; } },
|
|
170
|
+
'conv-cta': { label: 'Призыв к действию на первом экране', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки ${vb(n[0], 'ставит', 'ставят')} его в первых ${n[2]} процентах страницы`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
|
|
171
|
+
'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/pop-up/.test(v) ? `форма открывается во всплывающем окне по кнопке на ${nums(v)[0]} из ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'страницы' : 'страниц'} выборки; кнопка на первом экране на ${nums(v)[2]}` : /messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки)` : /no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : (Number(nums(v)[1]) === 1 ? `форма заявки есть на одной странице выборки, на ${pct(nums(v)[0])} вниз по коду страницы` : `медиана ${pct(nums(v)[0])} вниз по коду страницы, измерено на ${nums(v)[1]} ${Number(nums(v)[1]) % 10 === 1 && Number(nums(v)[1]) % 100 !== 11 ? 'странице' : 'страницах'}`)), comment: (c) => (/button that leads/.test(c) ? 'на первом экране уже есть кнопка, которая ведёт к форме' : c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
|
|
172
|
+
'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => (/only (enquiry )?form/.test(v) ? `в единственной форме заявки в выборке ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')}` : `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`), comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
|
|
160
173
|
'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
|
|
161
174
|
};
|
|
162
175
|
|
package/src/index.mjs
CHANGED
|
@@ -15,3 +15,4 @@ export { consistencyCheck } from './consistency.mjs';
|
|
|
15
15
|
export { detectNiche, vocabFor } from './niche.mjs';
|
|
16
16
|
export { renderTasksHtml, renderTasksPdf } from './tasks-pdf.mjs';
|
|
17
17
|
export { printFitted, pdfPageCount } from './render.mjs';
|
|
18
|
+
export { presentationCheck, STATUS_OK_SHARE, STATUS_OK_MIN } from './presentation.mjs';
|