@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 +2 -2
- package/src/collect.mjs +2 -2
- package/src/prompts.mjs +11 -2
- package/src/render.mjs +22 -2
- package/src/test-report-wording.mjs +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.33.
|
|
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
|
|
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 = [`### ${
|
|
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 =
|
|
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)}:
|
|
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всё сходится');
|