@operstack/audit 0.15.2 → 0.15.4

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.15.2",
3
+ "version": "0.15.4",
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
@@ -212,7 +212,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
212
212
  const disallows = blocks.flatMap((b) => b.rules.filter((r) => r.key === 'disallow' && r.value).map((r) => r.value));
213
213
  const starBlocksAll = blocks.some((b) => b.agents.includes('*') && b.rules.some((r) => r.key === 'disallow' && r.value === '/'));
214
214
  const blockedAgents = blocks.filter((b) => !b.agents.includes('*') && b.rules.some((r) => r.key === 'disallow' && r.value === '/')).flatMap((b) => b.agents);
215
- checks.push(row('robots', 'technical', 'robots.txt', robots.ok ? (sitemapUrls.length ? 'ok' : 'warn') : 'bad', robots.ok ? `${disallows.length} disallow rule(s) in ${blocks.length} agent block(s), ${sitemapUrls.length} sitemap line(s)` : `HTTP ${robots.status}`, robots.ok && !sitemapUrls.length ? 'no Sitemap: line' : ''));
215
+ checks.push(row('robots', 'technical', 'robots.txt', robots.ok ? (sitemapUrls.length ? 'ok' : 'warn') : 'bad', robots.ok ? `${disallows.length} disallow rule(s) in ${blocks.length} agent block(s), ${sitemapUrls.length ? `${sitemapUrls.length} sitemap line(s)` : 'no sitemap line'}` : `HTTP ${robots.status}`, robots.ok && !sitemapUrls.length ? 'no Sitemap: line' : ''));
216
216
  if (starBlocksAll) checks.push(row('robots-block', 'technical', 'robots.txt blocks the whole site', 'bad', 'Disallow: / under User-agent: *', 'search engines are told not to crawl anything'));
217
217
  // The two kinds of AI agent are not the same decision. A search fetcher is the one that can cite the
218
218
  // site in an answer, so blocking it costs visibility and is scored. A training crawler is a policy
@@ -317,15 +317,18 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
317
317
  const hp = homePage || good[0];
318
318
 
319
319
  if (hp) {
320
- checks.push(row('title', 'onpage', 'Homepage title', hp.titleLength >= 40 && hp.titleLength <= 60 ? 'ok' : hp.titleLength ? 'warn' : 'bad', `"${hp.title}" (${hp.titleLength} chars)`, hp.titleLength < 40 ? 'short: the words that earn the click are missing' : hp.titleLength > 60 ? 'cut in results' : ''));
321
- 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} chars`, hp.descriptionGarbage ? 'renders as technical garbage in the snippet' : !hp.descriptionLength ? 'missing' : ''));
322
- checks.push(row('h1', 'onpage', 'H1 on homepage', hp.h1Count === 1 ? 'ok' : hp.h1Count === 0 ? 'bad' : 'warn', `${hp.h1Count} H1 tag(s)`, hp.h1Count > 1 ? 'more than one H1 dilutes the page topic' : hp.h1Count === 0 ? 'no H1' : ''));
320
+ // Ноль в этих трёх строках означает «элемента нет», а не «маленькое значение». Строка
321
+ // «Сейчас: 0 знаков» в задании для исполнителя читается как обрывок: человеку непонятно,
322
+ // что описания у страницы просто не существует. Где ноль это отсутствие, пишем словами.
323
+ 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' : ''));
324
+ 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' : ''));
325
+ 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' : ''));
323
326
  checks.push(row('canonical', 'technical', 'Canonical tag', hp.canonical ? 'ok' : 'warn', hp.canonical || 'missing'));
324
327
  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' : ''));
325
328
  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'));
326
329
  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'));
327
330
  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'));
328
- checks.push(row('org-schema', 'geo', 'Organization or business schema', good.some((p) => p.schemaTypes.some((t) => /Organization|LocalBusiness|RealEstateAgent|Corporation/.test(t))) ? 'ok' : 'warn', good.some((p) => p.schemaTypes.some((t) => /Organization|LocalBusiness|RealEstateAgent|Corporation/.test(t))) ? 'present' : 'not found: answer engines have no entity to attach the site to'));
331
+ 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'));
329
332
  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)`));
330
333
  if (hp.generator) checks.push(row('cms', 'overview', 'CMS', 'na', hp.generator));
331
334
  checks.push(row('hreflang', 'technical', 'hreflang', hp.hreflang ? 'ok' : 'na', hp.hreflang ? `${hp.hreflang} alternate(s)` : 'none (single language)'));
package/src/i18n.mjs CHANGED
@@ -53,7 +53,7 @@ const RU = {
53
53
  ttfb: { label: 'Время до первого байта', value: (v) => `${nums(v)[0]} мс`, comment: () => 'один запрос от проверяющего, а не лабораторный тест' },
54
54
  robots: {
55
55
  label: 'robots.txt',
56
- value: (v) => { const n = nums(v); return /HTTP/.test(v) ? `не отдаётся, код ${n[0]}` : `${plural(Number(n[0]), 'запрещающее правило', 'запрещающих правила', 'запрещающих правил')} в ${plural(Number(n[1]), 'блоке', 'блоках', 'блоках')} агентов, ${plural(Number(n[2]), 'строка', 'строки', 'строк')} с картой сайта`; },
56
+ value: (v) => { const n = nums(v); if (/HTTP/.test(v)) return `не отдаётся, код ${n[0]}`; const map = /no sitemap line/i.test(v) ? 'строки с картой сайта нет' : `${plural(Number(n[2]), 'строка', 'строки', 'строк')} с картой сайта`; return `${plural(Number(n[0]), 'запрещающее правило', 'запрещающих правила', 'запрещающих правил')} в ${plural(Number(n[1]), 'блоке', 'блоках', 'блоках')} агентов, ${map}`; },
57
57
  comment: (c) => (/no Sitemap/.test(c) ? 'нет строки Sitemap' : ''),
58
58
  },
59
59
  'robots-block': { label: 'robots.txt закрывает весь сайт', value: () => 'Disallow: / под User-agent: *', comment: () => 'поисковым системам сказано не обходить ничего' },
@@ -80,9 +80,11 @@ const RU = {
80
80
  : `${plural(Number(nums(v)[0]), 'ссылка', 'ссылки', 'ссылок')}, ${nums(v)[1]} на другие хосты`),
81
81
  comment: (c) => (/nothing to read/.test(c) ? 'ответным системам нечего читать, генерация индекса это работа на день' : c ? 'система ИИ может принять сайт за чужой' : ''),
82
82
  },
83
- title: { label: 'Заголовок главной', value: (v) => { 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) ? 'в выдаче обрежется' : '') },
84
- description: { label: 'Описание главной', value: (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) ? 'описания нет' : '') },
85
- h1: { label: 'Заголовок H1 на главной', value: (v) => `${plural(Number(nums(v)[0] || 0), 'заголовок', 'заголовка', 'заголовков')} H1`, comment: (c) => (/dilutes/.test(c) ? 'больше одного H1 размывает тему страницы' : /no H1/.test(c) ? 'H1 на странице нет' : '') },
83
+ // Где ноль означает отсутствие элемента, по-русски это тоже пишется словами, а не цифрой:
84
+ // «0 знаков» в задании для исполнителя читается как обрывок.
85
+ 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) ? 'в выдаче обрежется' : '') },
86
+ 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) ? 'описания нет' : '') },
87
+ 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 на странице нет' : '') },
86
88
  canonical: { label: 'Канонический адрес', value: (v) => (/missing|none/i.test(v) ? 'не указан' : 'указан') },
87
89
  viewport: { label: 'Мобильный viewport', value: (v) => (/missing/.test(v) ? 'не задан' : v), comment: (c) => (c ? 'запрещает масштабирование: плохо для доступности и мобильного поиска' : '') },
88
90
  og: { label: 'Карточки для соцсетей', value: (v) => (/none/.test(v) ? 'нет' : v), comment: (c) => (/shortcode/.test(c) ? 'в описании служебный код вместо текста' : '') },
package/src/prompts.mjs CHANGED
@@ -162,11 +162,15 @@ export function renderAgentPrompts(audit, opts = {}) {
162
162
  const host = audit.meta?.host || '';
163
163
  const list = agentPrompts(audit, opts);
164
164
  const intro = lang === 'ru'
165
- ? [`# Задания для ИИ-агента: ${host}`, '',
166
- 'Это тот же список находок, что в отчёте, но переписанный так, чтобы его можно было отдать ИИ-агенту (Cursor, Claude Code, любому другому) и он сделал работу. Копируй задание целиком, вместе со строками «Сейчас» и «Как проверить»: без них агент не поймёт, откуда начинать и чем закончить.', '',
167
- 'Делайте по одному заданию за раз и проверяйте результат, прежде чем брать следующее. Агент, которому дали десять задач сразу, делает первую и половину остальных.', '']
168
- : [`# Prompts for an AI agent: ${host}`, '',
169
- 'This is the same list of findings as in the report, rewritten so you can hand it to an AI agent (Cursor, Claude Code, any other) and it does the work. Copy a task whole, including the "Now" and "How to check" lines: without them the agent does not know where it starts or when it is done.', '',
170
- 'Do one task at a time and check the result before taking the next. An agent given ten tasks at once does the first and half of the rest.', ''];
165
+ ? [`# Что поправить на сайте: ${host}`, '',
166
+ 'Ниже список задач, по одной на каждую найденную проблему. Каждая написана обычными словами, и её можно отдать кому угодно: тому, кто делал вам сайт, или помощнику вроде ChatGPT, Claude или Cursor.', '',
167
+ '**Как пользоваться.** Скопируйте одну задачу целиком, вместе со строками «Сейчас» и «Как проверить»: без них исполнитель не поймёт, откуда начинать и чем закончить. Отдайте её, дождитесь результата, сверьтесь со строкой «Как проверить» и берите следующую.', '',
168
+ 'По одной за раз, это важно. Помощник, которому дали десять задач сразу, делает первую и половину остальных.', '',
169
+ 'Кода знать не нужно. Ваше дело сказать, что сделать, и посмотреть на результат.', '']
170
+ : [`# What to fix on your site: ${host}`, '',
171
+ 'Below is a list of tasks, one for every problem found. Each is written in plain words and can be handed to anyone: whoever built your site, or an assistant like ChatGPT, Claude or Cursor.', '',
172
+ '**How to use it.** Copy one task whole, including the "Now" and "How to check" lines: without them nobody knows where to start or when it is done. Hand it over, wait for the result, check it against "How to check", then take the next one.', '',
173
+ 'One at a time, and this matters. An assistant given ten tasks at once does the first and half of the rest.', '',
174
+ 'You do not need to know any code. Your job is to say what to do and look at the result.', ''];
171
175
  return [intro.join('\n').trimEnd(), ...list.map((x) => x.text)].join('\n\n');
172
176
  }
@@ -51,10 +51,32 @@ const empty = mechanical.filter(([id]) => {
51
51
  }).map(([id]) => id);
52
52
  is('у каждой чинимой проверки есть внятное задание', empty, []);
53
53
 
54
+ // Ноль, который означает отсутствие, пишется словами, а не цифрой. 14.09.2026 в доставленном
55
+ // письме стояло «Сейчас: 0 знаков», и человек не понимал, что описания у страницы просто нет.
56
+ const zeros = [
57
+ ['description', 'no meta description on the page', /описания у страницы нет/],
58
+ ['title', 'no title on the page', /заголовка у страницы нет/],
59
+ ['h1', 'no H1 on the page', /заголовка H1 на странице нет/],
60
+ // У robots ноль правил это настоящее измерение, а не отсутствие: словами пишется только
61
+ // пропавшая строка Sitemap, поэтому в образце правил три.
62
+ ['robots', '3 disallow rule(s) in 2 agent block(s), no sitemap line', /строки с картой сайта нет/],
63
+ ];
64
+ const nowLine = (text) => (text.split('\n').find((l) => l.startsWith('**Сейчас:**')) || '');
65
+ for (const [id, value, expect] of zeros) {
66
+ const localised = renderAgentPrompts(audit([c(id, 'bad', id, value)], 'en'), { lang: 'ru' });
67
+ const now = nowLine(localised);
68
+ ok(`${id}: по-английски ноль не выводится цифрой`, !/(^|\s)0 (chars|H1|sitemap)/.test(value));
69
+ ok(`${id}: по-русски отсутствие названо словами`, expect.test(now));
70
+ // Проверяем именно строку «Сейчас»: в тексте задачи числа законны («в 120-160 знаков»).
71
+ ok(`${id}: в строке «Сейчас» нет обрывка с нулём`, !/(^|\s)0\s/.test(now));
72
+ }
73
+
54
74
  // Шапка файла объясняет, как этим пользоваться
55
75
  const head = renderAgentPrompts(audit([c('robots', 'bad', 'robots', 'закрыт')]), { lang: 'ru' });
56
- ok('шапка объясняет, что копировать', /Копируй задание целиком/.test(head));
57
- ok('и предупреждает делать по одному', /по одному заданию за раз/.test(head));
76
+ // Проверяем смысл, а не точную формулировку: шапку переписывали под покупателя без терминала.
77
+ ok('шапка объясняет, что копировать задачу целиком', /задачу целиком/i.test(head) && /Сейчас/.test(head) && /Как проверить/.test(head));
78
+ ok('и предупреждает делать по одной', /по одной за раз/i.test(head));
79
+ ok('и обещает, что код знать не нужно', /кода знать не нужно/i.test(head));
58
80
  ok('в файле нет пустых сдвоенных строк', !/\n\n\n/.test(head));
59
81
 
60
82
  if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }