@operstack/audit 0.33.2 → 0.33.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.33.2",
3
+ "version": "0.33.3",
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",
@@ -29,7 +29,7 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "scripts": {
32
- "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs",
32
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs && node src/test-report-wording.mjs",
33
33
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
34
34
  "verify:live": "node src/verify-live.mjs"
35
35
  },
package/src/collect.mjs CHANGED
@@ -817,7 +817,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
817
817
  site: 'Сам сайт', free: 'Бесплатно, без аккаунта',
818
818
  siteDetail: (n, viaLinks) => `robots.txt, карты сайта, llms.txt, главная и ${n} ${pagesRu(n)} выборки, прочитанные так же, как их читает любой посетитель${viaLinks ? `; ${viaLinks} из них найдены по ссылкам с главной, потому что в карте сайта их слишком мало` : ''}`,
819
819
  paid: 'Платный сервис, по желанию', paidDetail: 'только для справки, ни в одну оценку не входит',
820
- limitations: ['Google Search Console и Яндекс Вебмастер: показов, кликов и запросов нет', 'Аналитика: данных о посещениях и поведении нет', 'Сервисы ключевых слов и ссылок: в аудите по публичным сигналам не используются'],
820
+ limitations: ['Google Search Console и Яндекс Вебмастер: показов, кликов и запросов нет', 'Аналитика: данных о посещениях и поведении нет', 'Сервисы ключевых слов и ссылок: в проверке по публичным сигналам не используются'],
821
821
  fix: 'Исправить',
822
822
  roadmap: [['Неделя 1', 'Срочное, быстрые победы'], ['Недели 2-4', 'Основа'], ['Месяцы 2-3', 'Рост: поиск и ответы ИИ']],
823
823
  geo: ['Понятно ли, кто вы', 'Упоминания на других сайтах', 'Отзывы и пресса'],
@@ -836,7 +836,7 @@ export async function collect(startUrl, { pages = 20, pageDelayMs = 400, log = (
836
836
  const criticalShown = ru ? localised.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' })) : critical;
837
837
 
838
838
  return {
839
- meta: { site: home.final, host, reachable: homeAnswered, homeStatus: home.response.status || 0, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, tier, auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
839
+ meta: { site: home.final, host, reachable: homeAnswered, homeStatus: home.response.status || 0, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, tier, auditType: ['9', '29', 'watch', 'free'].includes(String(tier)) ? (lang === 'ru' ? 'Замер по публичным сигналам' : 'External check (no Search Console or analytics access)') : (lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)'), lang, language: hp ? (hp.og?.locale || '') : '' },
840
840
  client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: preparedBy || process.env.OPERSTACK_PREPARED_BY || 'OperStack' },
841
841
  overall,
842
842
  reportScore,
package/src/prompts.mjs CHANGED
@@ -236,6 +236,12 @@ const P = {
236
236
  ru: { task: 'Подними форму заявки выше на длинных страницах: либо перенеси её в первую треть, либо поставь вверху кнопку, которая прокручивает к форме. Сами поля и текст формы не меняй.', verify: 'На проверенных страницах форма или кнопка, ведущая к ней, видна в первой половине страницы, в том числе на телефоне.' },
237
237
  en: { task: 'Move the enquiry form higher on long pages: either into the first third, or add a button near the top that scrolls to it. Do not change the form fields or wording.', verify: 'On the sampled pages the form, or a button leading to it, sits in the first half of the page, on a phone too.' },
238
238
  },
239
+ // Формы на проверенных страницах нет вовсе (08.10.2026): «подними форму выше» тут противоречит
240
+ // строке «Сейчас». Отдельное задание: добавить форму, только если владелец принимает заявки на сайте.
241
+ 'conv-form-missing': {
242
+ ru: { task: 'На проверенных страницах нет формы заявки. Спроси владельца, принимает ли он заявки через сайт. Если да, добавь короткую форму (имя и один способ связи) в первую треть главной и страниц услуг или поставь вверху кнопку, которая ведёт к форме. Если заявки принимаются только по телефону или в мессенджере, форму не добавляй, а напиши владельцу, что этот пункт можно закрыть.', verify: 'На главной и страницах услуг форма или кнопка, ведущая к ней, видна в первой половине страницы, в том числе на телефоне; либо владелец ответил, что форма не нужна.' },
243
+ en: { task: 'There is no enquiry form on the sampled pages. Ask the owner whether they take enquiries through the site. If they do, add a short form (a name and one way to reach them) in the first third of the home page and the service pages, or a button near the top that leads to it. If enquiries come only by phone or messenger, add no form and tell the owner this item can be closed.', verify: 'On the home page and the service pages a form, or a button leading to it, shows in the first half of the page, on a phone too; or the owner has said no form is needed.' },
244
+ },
239
245
  'conv-form-fields': {
240
246
  ru: { task: 'Сократи форму заявки до четырёх полей или меньше: имя и один способ связи (телефон или мессенджер, на выбор), остальное необязательным или убрать. Какие поля нужны для работы, решает владелец: перечисли ему, что предлагаешь убрать, и убирай после его ответа.', verify: 'В форме не больше четырёх полей, и заявка с заполненными обязательными полями доходит до владельца.' },
241
247
  en: { task: 'Cut the enquiry form to four fields or fewer: a name and one way to reach them (phone or messenger, their choice), the rest optional or gone. The owner decides which fields the business needs: list what you propose to drop and drop it once they agree.', verify: 'The form has at most four fields, and a submission with the required fields reaches the owner.' },
@@ -342,11 +348,14 @@ const promptKey = (id) => (String(id || '').startsWith('engine-') ? 'engine' : i
342
348
  export function agentPrompt(check, opts = {}) {
343
349
  const lang = opts.lang === 'ru' ? 'ru' : 'en';
344
350
  const h = HEAD[lang];
345
- const own = P[promptKey(check.id)] ? withLang(P[promptKey(check.id)], lang) : null;
351
+ const noForm = check.id === 'conv-form-depth' && /no form|формы нет/i.test(String(check.value || ''));
352
+ const key = noForm ? 'conv-form-missing' : promptKey(check.id);
353
+ const own = P[key] ? withLang(P[key], lang) : null;
346
354
  const rule = FIX_ACTIONS[check.id];
347
355
  const fallback = rule && rule.action ? withLang(rule.action, lang) : (rule && rule.why ? withLang(rule.why, lang) : check.label);
356
+ const title = noForm ? (lang === 'ru' ? 'Форма заявки' : 'Enquiry form') : check.label;
348
357
 
349
- const lines = [`### ${check.label}`, '', `**${h.now}:** ${check.value}`, '', `**${h.task}:** ${own ? own.task : fallback}`];
358
+ const lines = [`### ${title}`, '', `**${h.now}:** ${check.value}`, '', `**${h.task}:** ${own ? own.task : fallback}`];
350
359
  // Проверка обязательна у каждого задания: без неё агент не знает, когда остановиться.
351
360
  lines.push('', `**${h.verify}:** ${own ? own.verify : h.genericVerify}`);
352
361
  const where = whereLine(check.id, opts.platform || null, lang);
package/src/render.mjs CHANGED
@@ -339,11 +339,31 @@ const list = (items) => `<ul>${(items || []).map((i) => `<li>${t(i)}</li>`).join
339
339
  const olist = (items) => `<ol>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ol>`;
340
340
  const statusCell = (s, L) => `<td class="${STATUS_CLASS[s] || STATUS_CLASS.na}">${L[STATUS_KEYS[s] || 'statusNa']}</td>`;
341
341
 
342
+ /*
343
+ * Название документа по тому, что купили (08.10.2026). Покупатель «Всех правок по сайту» за 800 ₽
344
+ * получал PDF с обложкой «Аудит сайта» и мог решить, что ему прислали не тот товар. Слово «аудит»
345
+ * остаётся только у аудита (tier audit) и у отчётов без ступени (запуск движка вручную); у ступеней
346
+ * 9, 29, watch и free документ называется отчётом.
347
+ */
348
+ const REPORT_TIERS = new Set(['9', '29', 'watch', 'free']);
349
+ const REPORT_WORDING = {
350
+ en: { eyebrowAudit: 'Measurement and fix list', coverTitle: 'SEO, AEO and GEO<br>site report', auditSubject: 'Site checked', auditType: 'Report type', runningHead: 'SEO, AEO and GEO report', docTitle: 'SEO, AEO and GEO report', from: [['this audit does not test', 'this report does not test'], ['re-run this audit yourself', 're-run this check yourself']] },
351
+ ru: { eyebrowAudit: 'Замер и список правок', coverTitle: 'Отчёт о сайте:<br>SEO, AEO и GEO', auditSubject: 'Что проверяли', auditType: 'Тип отчёта', runningHead: 'Отчёт SEO, AEO и GEO', docTitle: 'отчёт SEO, AEO и GEO', from: [['которую аудит не проверяет', 'которую этот отчёт не проверяет'], ['повторить этот аудит сами', 'повторить эту проверку сами']] },
352
+ };
353
+ export function labelsFor(lang, tier) {
354
+ const key = lang === 'ru' ? 'ru' : 'en';
355
+ const base = LABELS[key];
356
+ if (!REPORT_TIERS.has(String(tier ?? ''))) return { ...base, docTitle: key === 'ru' ? 'аудит SEO, AEO и GEO' : 'SEO, AEO and GEO audit' };
357
+ const w = REPORT_WORDING[key];
358
+ const swap = (txt) => w.from.reduce((t, [a, b]) => t.replace(a, b), txt);
359
+ return { ...base, eyebrowAudit: w.eyebrowAudit, coverTitle: w.coverTitle, auditSubject: w.auditSubject, auditType: w.auditType, runningHead: w.runningHead, docTitle: w.docTitle, scorecardFoot: swap(base.scorecardFoot), sourcesFoot: swap(base.sourcesFoot) };
360
+ }
361
+
342
362
  export function toHtml(audit, brand = null) {
343
363
  // Цвет агентства подменяет только акцент: остальная палитра отчёта остаётся выверенной.
344
364
  const accentCss = brand && brand.accent ? `:root{--accent:${brand.accent};--accent-soft:${softenHex(brand.accent)};}` : '';
345
365
  const a = audit;
346
- const L = LABELS[a.meta?.lang === 'ru' ? 'ru' : 'en'];
366
+ const L = labelsFor(a.meta?.lang, a.meta?.tier);
347
367
  const groups = [['technical', 'Technical'], ['onpage', 'On-page'], ['content', 'Content'], ['aeo', 'Answer engines'], ['geo', 'Generative engines'], ['offpage', 'Off-page and trust'], ['conversion', 'Conversion'], ['overview', 'Overview']];
348
368
  const checksBy = (g) => (a.checks || []).filter((c) => c.group === g);
349
369
  const { scores: measured, scoreBasis } = computeScores(a.checks || []);
@@ -658,7 +678,7 @@ export function toHtml(audit, brand = null) {
658
678
  if (a.demand && Array.isArray(a.demand.queries)) {
659
679
  pages.push(`<div class="page">${header(a, L.secDemand, L)}<div class="eyebrow">${L.demandEyebrow}</div><h2>${L.demandTitle}</h2>${renderDemandHtml(a.demand, { lang: a.meta?.lang === 'ru' ? 'ru' : 'en' })}${footer(a, n++, preparedBy)}</div>`);
660
680
  }
661
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: SEO, AEO and GEO audit</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}${accentCss}</style></head><body>${pages.join('\n')}</body></html>`;
681
+ return `<!doctype html><html lang="${a.meta?.lang === 'ru' ? 'ru' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: ${esc(L.docTitle)}</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}${accentCss}</style></head><body>${pages.join('\n')}</body></html>`;
662
682
  }
663
683
 
664
684
  function findChrome() {
@@ -0,0 +1,35 @@
1
+ // 08.10.2026: отчёт за 800 ₽ не называется аудитом; задание про форму не противоречит строке «Сейчас».
2
+ import { readFileSync } from 'node:fs';
3
+ import { labelsFor, toHtml } from './render.mjs';
4
+ import { agentPrompt } from './prompts.mjs';
5
+
6
+ let failed = 0;
7
+ const ok = (name, cond) => { console.log(`${cond ? 'ok ' : 'FAIL'} ${name}`); if (!cond) failed++; };
8
+
9
+ for (const tier of ['9', '29', 'watch', 'free']) {
10
+ const ru = labelsFor('ru', tier); const en = labelsFor('en', tier);
11
+ ok(`ступень ${tier}: русская обложка без слова «аудит»`, !/аудит/i.test(ru.coverTitle + ru.eyebrowAudit + ru.runningHead + ru.auditType + ru.docTitle));
12
+ ok(`ступень ${tier}: английская обложка без слова audit`, !/audit/i.test(en.coverTitle + en.eyebrowAudit + en.runningHead + en.auditType + en.docTitle));
13
+ ok(`ступень ${tier}: сноски без слова «аудит»`, !/аудит/i.test(ru.scorecardFoot + ru.sourcesFoot) && !/audit/i.test(en.scorecardFoot + en.sourcesFoot));
14
+ }
15
+ for (const tier of ['audit', '', undefined]) {
16
+ ok(`ступень «${tier}»: аудит остаётся аудитом`, /Аудит/.test(labelsFor('ru', tier).coverTitle) && /audit/.test(labelsFor('en', tier).coverTitle));
17
+ }
18
+
19
+ const sample = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url)));
20
+ const html9 = toHtml({ ...sample, meta: { ...sample.meta, lang: 'ru', tier: '9' } });
21
+ ok('PDF за 800 ₽: русский title и lang', /<html lang="ru">/.test(html9) && /: отчёт SEO, AEO и GEO<\/title>/.test(html9));
22
+ ok('PDF за 800 ₽: в шапках страниц «Отчёт», не «Аудит»', /Отчёт SEO, AEO и GEO ·/.test(html9) && !/Аудит SEO, AEO и GEO ·/.test(html9));
23
+ const htmlAudit = toHtml({ ...sample, meta: { ...sample.meta, lang: 'ru', tier: 'audit' } });
24
+ ok('аудит за 12 500 ₽: обложка прежняя', /Аудит сайта/.test(htmlAudit));
25
+
26
+ const noForm = agentPrompt({ id: 'conv-form-depth', label: 'На какой глубине лежит форма', value: 'на проверенных страницах формы нет', status: 'bad' }, { lang: 'ru' });
27
+ ok('формы нет: заголовок задачи «Форма заявки»', noForm.startsWith('### Форма заявки'));
28
+ ok('формы нет: задание не просит поднять форму', /нет формы заявки/.test(noForm) && !/Подними форму/.test(noForm));
29
+ const noFormEn = agentPrompt({ id: 'conv-form-depth', label: 'How far down the form sits', value: 'no form on the sampled pages', status: 'bad' }, { lang: 'en' });
30
+ ok('no form: English task asks first', /no enquiry form/.test(noFormEn) && !/Move the enquiry form higher/.test(noFormEn));
31
+ const deep = agentPrompt({ id: 'conv-form-depth', label: 'На какой глубине лежит форма', value: 'медиана 85% вниз по странице, измерено на 3 страницах', status: 'bad' }, { lang: 'ru' });
32
+ ok('форма есть, но низко: прежнее задание', /Подними форму/.test(deep));
33
+
34
+ if (failed) { console.log(`\n${failed} не сошлось`); process.exit(1); }
35
+ console.log('\nвсё сходится');