@operstack/audit 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/collect.mjs CHANGED
@@ -238,6 +238,32 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
238
238
  ? row('https', 'technical', 'HTTPS', home.final.startsWith('https://') ? 'ok' : 'bad', home.final.startsWith('https://') ? 'certificate active' : 'site served over http')
239
239
  : row('https', 'technical', 'HTTPS', 'bad', `the site did not answer${home.response.status ? `: HTTP ${home.response.status}` : ''}`, 'nothing below could be measured on a site that does not answer'));
240
240
  const altHost = host.startsWith('www.') ? host.slice(4) : `www.${host}`;
241
+ /*
242
+ * Цепочки переадресаций. Каждый прыжок это потерянное время у человека и потерянный вес ссылки
243
+ * у поиска, а нейросети на длинной цепочке чаще бросают страницу вовсе. Один прыжок нормален
244
+ * (например, с http на https), два уже стоит выпрямить, три и больше это поломка.
245
+ *
246
+ * Данные уже есть: follow() ходит с redirect: 'manual' и складывает каждый прыжок в hops.
247
+ * Добавлено 17.09.2026.
248
+ */
249
+ /*
250
+ * Значок сайта. Мелочь, но видимая: он стоит во вкладке, в закладках, в списке источников у
251
+ * нескольких ассистентов и в карточке при отправке ссылки. Его отсутствие читается как
252
+ * заброшенный сайт. Проверяем и файл по корневому адресу, и объявление в разметке: годится любое.
253
+ * Добавлено 17.09.2026.
254
+ */
255
+ const iconDeclared = /<link[^>]+rel=["'][^"']*icon/i.test(home.response.text || '');
256
+ const iconFile = iconDeclared ? { status: 0 } : await get(new URL('/favicon.ico', home.final).href, { method: 'HEAD' });
257
+ const hasIcon = iconDeclared || iconFile.status === 200;
258
+ checks.push(row('favicon', 'technical', 'Site icon', hasIcon ? 'ok' : 'warn',
259
+ iconDeclared ? 'declared in the page head' : iconFile.status === 200 ? '/favicon.ico answers 200' : 'no icon declared and /favicon.ico does not answer',
260
+ hasIcon ? '' : 'the tab, the bookmark and the shared card all fall back to a blank square'));
261
+
262
+ const homeHops = home.hops.filter((h) => h.status >= 300 && h.status < 400).length;
263
+ checks.push(row('redirect-chain', 'technical', 'Redirect hops to the homepage', homeHops >= 3 ? 'bad' : homeHops === 2 ? 'warn' : 'ok',
264
+ homeHops ? `${homeHops} redirect(s) before the page answers: ${home.hops.map((h) => `${h.status}`).join(' to ')}` : 'the address answers without a redirect',
265
+ homeHops >= 2 ? 'point the first address straight at the final one: every hop costs time and leaks link weight' : ''));
266
+
241
267
  const alt = await follow(`${new URL(home.final).protocol}//${altHost}/`);
242
268
  const altOk = alt.final.replace(/\/$/, '') === new URL(home.final).origin.replace(/\/$/, '') || alt.hops.some((h) => h.status >= 300 && h.status < 400);
243
269
  checks.push(row('www', 'technical', 'www canonicalisation', alt.response.status === 0 ? 'warn' : altOk ? 'ok' : 'warn', alt.response.status === 0 ? `${altHost} does not resolve` : altOk ? `${altHost} redirects to ${new URL(home.final).host}` : `${altHost} answers ${alt.response.status} without redirecting`));
@@ -266,7 +292,30 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
266
292
  const AI_FETCHERS = ['oai-searchbot', 'chatgpt-user', 'perplexitybot', 'perplexity-user', 'claude-searchbot', 'claude-user', 'bingbot', 'duckassistbot', 'applebot'];
267
293
  const blockedFetchers = blockedAgents.filter((a) => AI_FETCHERS.includes(a));
268
294
  const blockedTrainers = blockedAgents.filter((a) => !AI_FETCHERS.includes(a));
269
- checks.push(row('ai-search-access', 'geo', 'AI search fetchers allowed', blockedFetchers.length ? 'bad' : 'ok', blockedFetchers.length ? `${blockedFetchers.length} blocked: ${blockedFetchers.join(', ')}` : 'every AI search fetcher may read the site', blockedFetchers.length ? 'these are the agents that fetch a page to cite it in an answer' : ''));
295
+ /*
296
+ * Доступ по движкам, а не одной строкой. Раньше это была одна проверка на девять роботов, и
297
+ * человек видел «заблокировано 2» без понимания, кто именно его не увидит. «Perplexity вас не
298
+ * прочитает» действует сильнее, чем «два фетчера заблокированы», и чинится это по-разному: у
299
+ * каждого движка свой user-agent в robots.txt.
300
+ *
301
+ * Разбито 17.09.2026 после разбора конкурентов: у обоих готовность показана по движкам, и это
302
+ * единственное, в чём их подача была сильнее нашей по существу, а не по оформлению.
303
+ */
304
+ const ENGINES = [
305
+ { id: 'chatgpt', label: 'ChatGPT', agents: ['oai-searchbot', 'chatgpt-user'] },
306
+ { id: 'perplexity', label: 'Perplexity', agents: ['perplexitybot', 'perplexity-user'] },
307
+ { id: 'claude', label: 'Claude', agents: ['claude-searchbot', 'claude-user'] },
308
+ { id: 'copilot', label: 'Bing Copilot', agents: ['bingbot'] },
309
+ { id: 'gemini', label: 'Gemini', agents: ['google-extended', 'googleother'] },
310
+ { id: 'apple', label: 'Apple and DuckDuckGo', agents: ['applebot', 'duckassistbot'] },
311
+ ];
312
+ for (const e of ENGINES) {
313
+ const blocked = e.agents.filter((a) => blockedAgents.includes(a));
314
+ checks.push(row(`engine-${e.id}`, 'geo', `${e.label} may read the site`, blocked.length ? 'bad' : 'ok',
315
+ blocked.length ? `blocked in robots.txt: ${blocked.join(', ')}` : `allowed (${e.agents.join(', ')})`,
316
+ blocked.length ? `${e.label} will not fetch the page when a question needs it, so it cannot cite you even where you are the best answer` : ''));
317
+ }
318
+ checks.push(row('ai-search-access', 'geo', 'AI search fetchers allowed', blockedFetchers.length ? 'bad' : 'ok', blockedFetchers.length ? `${blockedFetchers.length} blocked: ${blockedFetchers.join(', ')}` : 'every AI search fetcher may read the site', blockedFetchers.length ? 'these are the agents that fetch a page while answering a question, not the training crawlers' : ''));
270
319
  if (blockedTrainers.length) checks.push(row('robots-ai', 'geo', 'AI training crawlers blocked (policy)', 'na', `${blockedTrainers.length} agent(s) fully disallowed: ${blockedTrainers.join(', ')}`, 'a policy choice, not a defect: these systems will not train on the site, and will not cite it from their own crawl either'));
271
320
 
272
321
  log('sitemaps');
@@ -379,11 +428,57 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
379
428
  checks.push(row('title', 'onpage', 'Homepage title', hp.titleLength >= 40 && hp.titleLength <= 60 ? 'ok' : hp.titleLength ? 'warn' : 'bad', hp.titleLength ? `"${hp.title}" (${hp.titleLength} chars)` : 'no title on the page', hp.titleLength < 40 ? 'short: the words that earn the click are missing' : hp.titleLength > 60 ? 'cut in results' : ''));
380
429
  checks.push(row('description', 'onpage', 'Homepage meta description', hp.descriptionGarbage ? 'bad' : hp.descriptionLength >= 70 && hp.descriptionLength <= 160 ? 'ok' : hp.descriptionLength ? 'warn' : 'bad', hp.descriptionGarbage ? 'contains a shortcode or encoded data' : hp.descriptionLength ? `${hp.descriptionLength} chars` : 'no meta description on the page', hp.descriptionGarbage ? 'renders as technical garbage in the snippet' : !hp.descriptionLength ? 'missing' : ''));
381
430
  checks.push(row('h1', 'onpage', 'H1 on homepage', hp.h1Count === 1 ? 'ok' : hp.h1Count === 0 ? 'bad' : 'warn', hp.h1Count ? `${hp.h1Count} H1 tag(s)` : 'no H1 on the page', hp.h1Count > 1 ? 'more than one H1 dilutes the page topic' : hp.h1Count === 0 ? 'no H1' : ''));
382
- checks.push(row('canonical', 'technical', 'Canonical tag', hp.canonical ? 'ok' : 'warn', hp.canonical || 'missing'));
431
+ /*
432
+ * Раньше canonical смотрелся только на главной, хотя десять страниц уже прочитаны. Так и
433
+ * прожила незамеченной страница /privacy/ на moregroup.estate: без title, без canonical, без
434
+ * Open Graph и без разметки, и при этом в карте сайта. Нашлась 17.09.2026 только потому, что
435
+ * проверку Open Graph разбили на постраничную.
436
+ *
437
+ * Правило простое: если страницы уже скачаны, судить надо по всем, а не по одной.
438
+ */
439
+ checks.push(row('canonical', 'technical', 'Canonical tag', good.every((x) => x.canonical) ? 'ok' : good.some((x) => x.canonical) ? 'warn' : 'bad',
440
+ `${good.filter((x) => x.canonical).length} of ${good.length} sampled page(s) carry a canonical tag`,
441
+ good.every((x) => x.canonical) ? '' : `missing on: ${good.filter((x) => !x.canonical).map((x) => new URL(x.url).pathname).slice(0, 5).join(', ')}`));
442
+ checks.push(row('title-all', 'onpage', 'Title on the sampled pages', good.every((x) => x.titleLength) ? 'ok' : 'bad',
443
+ `${good.filter((x) => x.titleLength).length} of ${good.length} sampled page(s) carry a title`,
444
+ good.every((x) => x.titleLength) ? '' : `missing on: ${good.filter((x) => !x.titleLength).map((x) => new URL(x.url).pathname).slice(0, 5).join(', ')}`));
445
+ checks.push(row('description-all', 'onpage', 'Meta description on the sampled pages', good.every((x) => x.descriptionLength) ? 'ok' : good.some((x) => x.descriptionLength) ? 'warn' : 'bad',
446
+ `${good.filter((x) => x.descriptionLength).length} of ${good.length} sampled page(s) carry a meta description`,
447
+ good.every((x) => x.descriptionLength) ? '' : `missing on: ${good.filter((x) => !x.descriptionLength).map((x) => new URL(x.url).pathname).slice(0, 5).join(', ')}`));
448
+ checks.push(row('schema-all', 'aeo', 'Structured data on the sampled pages', good.every((x) => x.schemaTypes.length) ? 'ok' : good.some((x) => x.schemaTypes.length) ? 'warn' : 'bad',
449
+ `${good.filter((x) => x.schemaTypes.length).length} of ${good.length} sampled page(s) carry JSON-LD`,
450
+ good.every((x) => x.schemaTypes.length) ? '' : `missing on: ${good.filter((x) => !x.schemaTypes.length).map((x) => new URL(x.url).pathname).slice(0, 5).join(', ')}`));
383
451
  checks.push(row('viewport', 'technical', 'Mobile viewport', !hp.viewport ? 'bad' : /user-scalable\s*=\s*no|maximum-scale\s*=\s*1(\.0)?\b/i.test(hp.viewport) ? 'warn' : 'ok', hp.viewport || 'missing', /user-scalable\s*=\s*no/i.test(hp.viewport) ? 'blocks zoom: bad for accessibility and mobile SEO' : ''));
384
- checks.push(row('og', 'technical', 'Open Graph and Twitter cards', hp.og.title && hp.og.image ? (hp.twitter ? 'ok' : 'warn') : 'warn', [hp.og.title ? 'og:title' : '', hp.og.image ? 'og:image' : '', hp.twitter ? `twitter:${hp.twitter}` : ''].filter(Boolean).join(', ') || 'none'));
452
+ /*
453
+ * Раньше это была одна проверка на og:title, og:image и Twitter Card. Это три разные правки в
454
+ * трёх разных строках шаблона, и человеку, у которого не хватает одной, бесполезно читать про
455
+ * все три сразу. Разбито 17.09.2026.
456
+ */
457
+ const onAll = (f) => good.filter(f).length;
458
+ 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',
459
+ `${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'));
460
+ 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',
461
+ `${onAll((p) => p.og.image)} of ${good.length} sampled page(s) carry og:image`, onAll((p) => p.og.image) === good.length ? '' : 'a link without a picture is scrolled past in every messenger'));
462
+ checks.push(row('twitter-card', 'technical', 'Twitter Card', hp.twitter ? 'ok' : 'warn', hp.twitter ? `twitter:card ${hp.twitter}` : 'no twitter:card on the homepage',
463
+ hp.twitter ? '' : 'X and several readers fall back to it when Open Graph is incomplete'));
385
464
  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'));
386
465
  checks.push(row('faq-schema', 'aeo', 'FAQPage schema', good.some((p) => p.schemaTypes.includes('FAQPage')) ? 'ok' : 'warn', good.some((p) => p.schemaTypes.includes('FAQPage')) ? `present on ${good.filter((p) => p.schemaTypes.includes('FAQPage')).length} sampled page(s)` : 'not found in the sample'));
466
+ /*
467
+ * Отдельные типы разметки вместо одной общей строки «structured data». Каждый из них движки
468
+ * забирают по-разному: хлебные крошки показывают путь в ответе, HowTo забирают пошаговые
469
+ * инструкции целиком, Product несёт цену и наличие. Человеку, у которого нет крошек, незачем
470
+ * читать про HowTo. Разбито 17.09.2026.
471
+ */
472
+ const withType = (t) => good.filter((p) => p.schemaTypes.includes(t));
473
+ checks.push(row('breadcrumbs-schema', 'aeo', 'BreadcrumbList schema', withType('BreadcrumbList').length ? 'ok' : 'warn',
474
+ withType('BreadcrumbList').length ? `present on ${withType('BreadcrumbList').length} sampled page(s)` : 'not found in the sample',
475
+ withType('BreadcrumbList').length ? '' : 'breadcrumbs tell an engine where the page sits, and they show up in the answer beside the link'));
476
+ checks.push(row('howto-schema', 'aeo', 'HowTo schema', withType('HowTo').length ? 'ok' : 'na',
477
+ withType('HowTo').length ? `present on ${withType('HowTo').length} sampled page(s)` : 'no step-by-step page in the sample',
478
+ withType('HowTo').length ? '' : 'only worth adding where a page really walks through steps: marking anything else HowTo is a lie an engine will learn'));
479
+ checks.push(row('product-schema', 'aeo', 'Product schema', withType('Product').length ? 'ok' : 'na',
480
+ withType('Product').length ? `present on ${withType('Product').length} sampled page(s)` : 'no product page in the sample',
481
+ withType('Product').length ? '' : 'only for pages that really sell a named thing with a price'));
387
482
  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'));
388
483
  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)`));
389
484
  if (hp.generator) checks.push(row('cms', 'overview', 'CMS', 'na', hp.generator));
@@ -409,8 +504,36 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
409
504
  checks.push(row('sections', 'aeo', 'Section structure', structured === good.length ? 'ok' : structured ? 'warn' : 'bad', `${structured} of ${good.length} sampled page(s) carry three or more H2 sections`, structured === good.length ? '' : 'engines quote sections, not walls of text'));
410
505
  const withTables = good.filter((p) => p.tables > 0).length;
411
506
  checks.push(row('tables', 'aeo', 'Tables in the content', withTables ? 'ok' : 'warn', withTables ? `${withTables} of ${good.length} sampled page(s) use a table` : 'no table on the sampled pages', withTables ? '' : 'tables are the second most quoted format after the opening paragraph'));
412
- const sourced = good.filter((p) => p.sourcePhrases > 0 || p.citedParagraphs > 0).length;
413
- checks.push(row('sources', 'content', 'Sources named in the text', sourced === good.length ? 'ok' : sourced ? 'warn' : 'bad', `${sourced} of ${good.length} sampled page(s) name where a figure comes from, in words or as an outbound link beside the figure`, sourced === good.length ? '' : 'an unsourced figure is the first thing an engine drops'));
507
+ /*
508
+ * Источник у цифры. Правило не наше: в работе Aggarwal и др. (KDD 2024, 10 000 запросов,
509
+ * девять способов правки) «назвать источник» дало самый большой прирост видимости из всех
510
+ * протестированных, от 30 до 40 процентов. Рядом сработали «добавить цифру» (+37) и «добавить
511
+ * цитату» (+22), а набивка ключевых слов не сработала вовсе.
512
+ *
513
+ * Судим только те страницы, на которых цифры ЕСТЬ. Раньше страница без единой цифры считалась
514
+ * непроставленным источником и снижала балл, хотя источать ей нечего. Это и был тот самый
515
+ * штраф ни за что, на который справедливо пожаловался владелец 17.09.2026.
516
+ *
517
+ * Страницы товара и услуги исключены отдельно: цена на своей же карточке товара не нуждается
518
+ * во внешнем источнике, она и есть первоисточник.
519
+ *
520
+ * Чего мы тут НЕ умеем и о чём честно пишем ниже: в том же исследовании эффект зависит от
521
+ * текущей позиции страницы (с пятого места +115%, с первого −30%). Позиции поиска публичные
522
+ * сигналы не показывают, их видно только в Search Console владельца, поэтому в бесплатной
523
+ * проверке различить это нельзя.
524
+ */
525
+ const SELF_SOURCED = /^(Product|Offer|Service|AggregateOffer|SoftwareApplication|Course|Event|JobPosting|Recipe|RealEstateListing)$/;
526
+ const judged = good.filter((p) => p.figureParagraphs > 0 && !p.schemaTypes.some((t) => SELF_SOURCED.test(t)));
527
+ if (judged.length) {
528
+ const sourced = judged.filter((p) => p.sourcePhrases > 0 || p.citedParagraphs > 0).length;
529
+ checks.push(row('sources', 'content', 'Sources named in the text', sourced === judged.length ? 'ok' : sourced ? 'warn' : 'bad',
530
+ `${sourced} of ${judged.length} page(s) carrying figures name where the figure comes from, in words or as an outbound link beside it${judged.length < good.length ? ` (${good.length - judged.length} page(s) not judged: no figures, or a product or service page whose own price is the source)` : ''}`,
531
+ sourced === judged.length ? '' : 'naming the source was the single strongest tested way to be quoted (Aggarwal et al., KDD 2024: +30 to +40%); it matters most on pages that are not already ranking first, which public signals cannot tell us'));
532
+ } else {
533
+ checks.push(row('sources', 'content', 'Sources named in the text', 'na',
534
+ 'no sampled page carries a figure that would need a source',
535
+ 'nothing to judge: a page without figures is not penalised for failing to source them'));
536
+ }
414
537
  }
415
538
  const dated = good.filter((p) => p.dates.modified || p.dates.published).length;
416
539
  if (good.length > 2) checks.push(row('dates', 'content', 'Publication dates exposed', dated ? 'ok' : 'warn', dated ? `${dated} of ${good.length} sampled pages expose dates` : 'no article dates in the sample: answer engines cannot tell what is current'));
package/src/i18n.mjs CHANGED
@@ -93,7 +93,26 @@ const RU = {
93
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) ? 'в выдаче обрежется' : '') },
94
94
  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
95
  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
- canonical: { label: 'Канонический адрес', value: (v) => (/missing|none/i.test(v) ? 'не указан' : 'указан') },
96
+ 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
+ // Добавлено 17.09.2026 вместе с новыми проверками. Русский отчёт платный, английские строки в нём
98
+ // это такой же брак, как русский отчёт англоязычному покупателю.
99
+ 'title-all': { label: 'Заголовок на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
100
+ 'description-all': { label: 'Описание на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
101
+ 'schema-all': { label: 'Разметка на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
102
+ '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 ? 'ссылку без картинки пролистывают в любом мессенджере' : '') },
104
+ 'twitter-card': { label: 'Карточка Twitter', value: (v) => (/no twitter/.test(v) ? 'не задана' : v.replace('twitter:card', 'тип')), comment: (c) => (c ? 'на неё опираются X и часть читалок, когда Open Graph неполный' : '') },
105
+ 'breadcrumbs-schema': { label: 'Разметка хлебных крошек', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${pages(nums(v)[0])} выборки`), comment: (c) => (c ? 'крошки говорят системе, где страница стоит, и показываются в ответе рядом со ссылкой' : '') },
106
+ 'howto-schema': { label: 'Разметка пошаговых инструкций', value: (v) => (/no step-by-step/.test(v) ? 'пошаговых страниц в выборке нет' : `есть на ${pages(nums(v)[0])} выборки`), comment: (c) => (c ? 'ставить только там, где страница правда ведёт по шагам: разметить ею что-то другое значит соврать системе' : '') },
107
+ 'product-schema': { label: 'Разметка товара', value: (v) => (/no product/.test(v) ? 'страниц товара в выборке нет' : `есть на ${pages(nums(v)[0])} выборки`), comment: (c) => (c ? 'только для страниц, которые правда продают названную вещь с ценой' : '') },
108
+ 'redirect-chain': { label: 'Переадресации до главной', value: (v) => (/without a redirect/.test(v) ? 'адрес отвечает сразу, без переадресаций' : `${plural(Number(nums(v)[0]), 'переадресация', 'переадресации', 'переадресаций')} до ответа`), comment: (c) => (c ? 'направьте первый адрес сразу на конечный: каждый прыжок стоит времени и веса ссылки' : '') },
109
+ favicon: { label: 'Значок сайта', value: (v) => (/declared/.test(v) ? 'объявлен в шапке страницы' : /answers 200/.test(v) ? '/favicon.ico отвечает' : 'не объявлен, и /favicon.ico не отвечает'), comment: (c) => (c ? 'вкладка, закладка и карточка при пересылке ссылки останутся с пустым квадратом' : '') },
110
+ 'engine-chatgpt': { label: 'ChatGPT может читать сайт', value: (v) => (/blocked/.test(v) ? `закрыт в robots.txt: ${v.replace(/^blocked in robots.txt:\s*/, '')}` : 'открыт'), comment: (c) => (c ? 'ChatGPT не заберёт страницу, когда она понадобится для ответа, и не процитирует вас даже там, где вы лучший ответ' : '') },
111
+ 'engine-perplexity': { label: 'Perplexity может читать сайт', value: (v) => (/blocked/.test(v) ? `закрыт в robots.txt: ${v.replace(/^blocked in robots.txt:\s*/, '')}` : 'открыт'), comment: (c) => (c ? 'Perplexity не заберёт страницу, когда она понадобится для ответа' : '') },
112
+ 'engine-claude': { label: 'Claude может читать сайт', value: (v) => (/blocked/.test(v) ? `закрыт в robots.txt: ${v.replace(/^blocked in robots.txt:\s*/, '')}` : 'открыт'), comment: (c) => (c ? 'Claude не заберёт страницу, когда она понадобится для ответа' : '') },
113
+ 'engine-copilot': { label: 'Bing Copilot может читать сайт', value: (v) => (/blocked/.test(v) ? `закрыт в robots.txt: ${v.replace(/^blocked in robots.txt:\s*/, '')}` : 'открыт'), comment: (c) => (c ? 'Copilot не заберёт страницу, когда она понадобится для ответа' : '') },
114
+ 'engine-gemini': { label: 'Gemini может читать сайт', value: (v) => (/blocked/.test(v) ? `закрыт в robots.txt: ${v.replace(/^blocked in robots.txt:\s*/, '')}` : 'открыт'), comment: (c) => (c ? 'Gemini не заберёт страницу, когда она понадобится для ответа' : '') },
115
+ 'engine-apple': { label: 'Apple и DuckDuckGo могут читать сайт', value: (v) => (/blocked/.test(v) ? `закрыт в robots.txt: ${v.replace(/^blocked in robots.txt:\s*/, '')}` : 'открыт'), comment: (c) => (c ? 'их ассистенты не заберут страницу, когда она понадобится для ответа' : '') },
97
116
  viewport: { label: 'Мобильный viewport', value: (v) => (/missing/.test(v) ? 'не задан' : v), comment: (c) => (c ? 'запрещает масштабирование: плохо для доступности и мобильного поиска' : '') },
98
117
  og: { label: 'Карточки для соцсетей', value: (v) => (/none/.test(v) ? 'нет' : v), comment: (c) => (/shortcode/.test(c) ? 'в описании служебный код вместо текста' : '') },
99
118
  schema: { label: 'Разметка на главной', value: (v) => (/no JSON-LD/.test(v) ? 'разметки нет' : /invalid/.test(v) ? 'разметка есть, но с ошибкой' : v) },