@operstack/audit 0.17.1 → 0.17.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.17.1",
3
+ "version": "0.17.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",
package/src/collect.mjs CHANGED
@@ -500,9 +500,14 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
500
500
  if (visibility) log('visibility score: taken from the check the visitor already ran');
501
501
  else { log('visibility score'); visibility = await checkVisibility(startUrl, { ...VISIBILITY_DEFAULTS, lang }); }
502
502
  const measuredAt = visibility && visibility.checkedAt ? visibility.checkedAt : null;
503
+ /* Из чего сложился балл, словами для отчёта: сколько страниц прочитано и дочиталась ли карта
504
+ * сайта. Без этого подпись под баллом обещает точность, которой у восьмисекундной проверки нет. */
505
+ const basis = visibility && visibility.ok
506
+ ? { pages: (visibility.sample || []).length, sitemapRead: Boolean(visibility.sitemap && visibility.sitemap.found), sitemapUnchecked: Boolean(visibility.sitemap && visibility.sitemap.unchecked) }
507
+ : null;
503
508
  const overall = visibility && visibility.ok
504
- ? { score: visibility.score, grade: visibility.grade, areas: visibility.areas, source: givenVisibility ? 'visibility:reused' : 'visibility', measuredAt, ms: visibility.ms }
505
- : { score: null, grade: 'not measured', areas: [], source: givenVisibility ? 'visibility:reused' : 'visibility', error: (visibility && visibility.error) || 'no visibility result' };
509
+ ? { score: visibility.score, grade: visibility.grade, areas: visibility.areas, basis, source: givenVisibility ? 'visibility:reused' : 'visibility', measuredAt, ms: visibility.ms }
510
+ : { score: null, grade: 'not measured', areas: [], basis: null, source: givenVisibility ? 'visibility:reused' : 'visibility', error: (visibility && visibility.error) || 'no visibility result' };
506
511
  // Шесть областей отчёта остаются, но это другое измерение, а не разбивка общего балла.
507
512
  const reportScore = computeOverall(checks);
508
513
 
package/src/render.mjs CHANGED
@@ -29,7 +29,15 @@ const LABELS = {
29
29
  grades: { A: 'Strong', B: 'Workable', C: 'Weak', D: 'Poor', E: 'Critical' },
30
30
  // Заголовок это то же измерение, что на странице проверки. Подпись обязана это говорить,
31
31
  // иначе читатель снова начнёт сводить его с шестью областями ниже.
32
- overallNote: 'The same measurement the free check at oper-stack.com/ai-visibility gives for this site: the same code, the same settings. The five areas below add up to it exactly, so you can recompute it by hand.',
32
+ // Подпись обязана сказать три вещи: откуда число, что именно успели прочитать, и почему
33
+ // повтор может дать на пункт-другой иначе. Без последнего человек читает разницу как ошибку.
34
+ overallNote: (o) => {
35
+ const read = o.basis ? `It was read from ${o.basis.pages} page${o.basis.pages === 1 ? '' : 's'} of the site${o.basis.sitemapUnchecked ? ', and the sitemap did not answer in time, which is not counted against the score either way' : o.basis.sitemapRead ? ' and its sitemap' : ''}. ` : '';
36
+ const where = o.source === 'visibility:reused'
37
+ ? `This is the same figure you already saw on the free check${o.measuredAt ? ` on ${String(o.measuredAt).slice(0, 10)}` : ''}. It is not measured again here, so the page, the email and this report always carry one number. `
38
+ : 'Measured here by the same code and the same settings as the free check at oper-stack.com/ai-visibility. ';
39
+ return `${where}${read}The five areas below add up to it exactly, so you can recompute it by hand. Running the check again can move it by a point or two: a check that answers in time on one run may not on the next, and this score never counts against a site what it could not read.`;
40
+ },
33
41
  secondMeasure: 'A second, separate measurement: how this report scores its own checks',
34
42
  secOverview: 'Site overview', overviewEyebrow: '02 · Overview', whatSiteIs: 'What the site is', parameter: 'Parameter', value: 'Value', pagesSampled: 'Pages sampled', colUrl: 'URL', colTitle: 'Title', colWords: 'Words',
35
43
  secCritical: 'Critical issues', criticalEyebrow: '03 · P0', criticalTitle: 'Critical issues, fix first', whatItCosts: 'What it costs', theFix: 'Fix', noBody: 'This issue has no description in the audit file.', noCritical: 'No critical defects were found in the public signals.',
@@ -56,7 +64,15 @@ const LABELS = {
56
64
  notMeasured: 'не измерялось',
57
65
  overallLabel: 'Балл видимости в ИИ',
58
66
  grades: { A: 'Сильно', B: 'Рабочее состояние', C: 'Слабо', D: 'Плохо', E: 'Критично' },
59
- overallNote: 'То же измерение, что даёт бесплатная проверка на oper-stack.com/ai-visibility для этого сайта: тот же код, те же настройки. Пять областей ниже дают в сумме ровно этот балл, его можно сложить руками.',
67
+ overallNote: (o) => {
68
+ const p = o.basis ? o.basis.pages : 0;
69
+ const pl = ruPages(p);
70
+ const read = o.basis ? `Прочитано ${p} ${pl} сайта${o.basis.sitemapUnchecked ? ', а карта сайта не ответила вовремя, и это не зачтено ни в плюс, ни в минус' : o.basis.sitemapRead ? ' и его карта' : ''}. ` : '';
71
+ const where = o.source === 'visibility:reused'
72
+ ? `Это ровно то число, которое вы уже видели в бесплатной проверке${o.measuredAt ? ` ${String(o.measuredAt).slice(0, 10)}` : ''}. Здесь оно не меряется заново, поэтому страница, письмо и этот отчёт всегда несут одно число. `
73
+ : 'Измерено здесь тем же кодом и с теми же настройками, что у бесплатной проверки на oper-stack.com/ai-visibility. ';
74
+ return `${where}${read}Пять областей ниже дают в сумме ровно этот балл, его можно сложить руками. Повторная проверка может дать на пункт-другой иначе: проверка, которая успела ответить в один прогон, может не успеть в следующий, а балл никогда не засчитывает сайту в минус то, что не удалось прочитать.`;
75
+ },
60
76
  secondMeasure: 'Второе, отдельное измерение: как этот отчёт оценивает собственные проверки',
61
77
  secOverview: 'О сайте', overviewEyebrow: '02 · Обзор', whatSiteIs: 'Что это за сайт', parameter: 'Параметр', value: 'Значение', pagesSampled: 'Проверенные страницы', colUrl: 'Адрес', colTitle: 'Заголовок', colWords: 'Слов',
62
78
  secCritical: 'Критичное', criticalEyebrow: '03 · Срочно', criticalTitle: 'Что чинить первым', whatItCosts: 'Чем это грозит', theFix: 'Как чинится', noBody: 'У этой проблемы нет описания в файле аудита.', noCritical: 'Критичных дефектов в публичных сигналах не найдено.',
@@ -78,6 +94,9 @@ const LABELS = {
78
94
  const STATUS_KEYS = { ok: 'statusOk', warn: 'statusWarn', bad: 'statusBad', na: 'statusNa' };
79
95
  const STATUS_CLASS = { ok: 'status-ok', warn: 'status-warn', bad: 'status-bad', na: 'status-na' };
80
96
  const STATUS_UNUSED = { ok: ['✓ OK', 'status-ok'], warn: ['△ Partial', 'status-warn'], bad: ['✗ Problem', 'status-bad'], na: ['· Note', 'status-na'] };
97
+ /** Склонение «страница»: 1 страница, 4 страницы, 5 страниц. Подпись под баллом читает человек. */
98
+ const ruPages = (n) => { const a = Math.abs(n) % 100; const b = a % 10; if (a > 10 && a < 20) return 'страниц'; if (b === 1) return 'страница'; if (b >= 2 && b <= 4) return 'страницы'; return 'страниц'; };
99
+
81
100
  /** Склонение «балл» для русской подписи под общим баллом: 21 балл, 72 балла, 75 баллов. */
82
101
  const ruPoints = (n) => { const a = Math.abs(n) % 100; const b = a % 10; if (a > 10 && a < 20) return 'баллов'; if (b === 1) return 'балл'; if (b >= 2 && b <= 4) return 'балла'; return 'баллов'; };
83
102
 
@@ -198,7 +217,7 @@ export function toHtml(audit, brand = null) {
198
217
  <div class="overall-num ${scoreClass(Math.round(overall.score / 10))}">${overall.score}<span>/100</span></div>
199
218
  <div class="overall-side"><div class="overall-label">${esc(L.overallLabel)}</div>
200
219
  <div class="overall-grade">${esc((L.grades && L.grades[overall.grade]) || overall.grade)}</div>
201
- <div class="overall-note">${esc(L.overallNote)}</div></div></div>`
220
+ <div class="overall-note">${esc(L.overallNote(overall))}</div></div></div>`
202
221
  + (overallAreas.length ? `<table class="areas"><tr>${overallAreas.map((x) => `<th>${esc(x.label)}</th>`).join('')}</tr><tr>${overallAreas.map((x) => `<td>${x.score}<span>/${x.max}</span></td>`).join('')}</tr></table>` : '');
203
222
  const scoreCards = Object.entries(measured).map(([label, n]) => {
204
223
  const basis = scoreBasis[label] || {};
@@ -147,6 +147,45 @@ ok('параметры нельзя поменять на ходу', Object.isFr
147
147
  is('и отчёт печатает именно его', Number((toHtml(audit, null).match(/overall-num[^>]*>(\d+)<span>/) || [])[1]), 63);
148
148
  }
149
149
 
150
+ /*
151
+ * Подпись под баллом обязана объяснить человеку три вещи, иначе он прочитает разницу в пару
152
+ * пунктов как ошибку: откуда взялось число, что успели прочитать, и почему повтор может дать
153
+ * иначе. Это критерий приёмки, а не украшение: человек, прочитавший сначала страницу, а потом
154
+ * отчёт, не должен удивиться.
155
+ */
156
+ {
157
+ const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
158
+ const areas = [{ id: 'access', label: 'Access', score: 20, max: 25 }, { id: 'index', label: 'Index', score: 10, max: 15 },
159
+ { id: 'entity', label: 'Entity', score: 12, max: 20 }, { id: 'content', label: 'Content', score: 14, max: 25 }, { id: 'trust', label: 'Trust', score: 5, max: 15 }];
160
+ const note = (a) => ((toHtml(a, null).match(/overall-note">([^<]*)</) || [])[1] || '');
161
+
162
+ for (const lang of ['en', 'ru']) {
163
+ audit.meta.lang = lang;
164
+
165
+ audit.overall = { score: 61, grade: 'B', areas, source: 'visibility:reused', measuredAt: '2026-09-14T20:00:00.000Z', basis: { pages: 4, sitemapRead: true, sitemapUnchecked: false } };
166
+ const reused = note(audit);
167
+ ok(`${lang}: сказано, что число уже видели на проверке`, lang === 'en' ? /already saw on the free check/.test(reused) : /уже видели в бесплатной проверке/.test(reused));
168
+ ok(`${lang}: сказано, что здесь не меряли заново`, lang === 'en' ? /not measured again here/.test(reused) : /не меряется заново/.test(reused));
169
+ ok(`${lang}: названа дата измерения`, /2026-09-14/.test(reused));
170
+ ok(`${lang}: сказано, сколько страниц прочитано`, /4/.test(reused));
171
+ ok(`${lang}: предупреждает про разброс в пункт-другой`, lang === 'en' ? /a point or two/.test(reused) : /на пункт-другой/.test(reused));
172
+
173
+ audit.overall = { score: 61, grade: 'B', areas, source: 'visibility', basis: { pages: 1, sitemapRead: false, sitemapUnchecked: true } };
174
+ const fresh = note(audit);
175
+ ok(`${lang}: при собственном измерении сказано именно это`, lang === 'en' ? /Measured here by the same code/.test(fresh) : /Измерено здесь тем же кодом/.test(fresh));
176
+ ok(`${lang}: непрочитанная карта сайта названа, а не спрятана`, lang === 'en' ? /sitemap did not answer in time/.test(fresh) : /карта сайта не ответила вовремя/.test(fresh));
177
+ ok(`${lang}: и сказано, что она не зачтена в минус`, lang === 'en' ? /not counted against the score/.test(fresh) : /не зачтено ни в плюс, ни в минус/.test(fresh));
178
+ ok(`${lang}: в подписи нет чужого языка`, lang === 'en' ? !/[А-Яа-яЁё]/.test(fresh) : !/Measured|sitemap did/.test(fresh));
179
+ }
180
+
181
+ // Русское склонение в подписи: человек это читает.
182
+ audit.meta.lang = 'ru';
183
+ for (const [n, word] of [[1, 'страница'], [2, 'страницы'], [5, 'страниц'], [11, 'страниц'], [21, 'страница']]) {
184
+ audit.overall = { score: 61, grade: 'B', areas, source: 'visibility', basis: { pages: n, sitemapRead: true, sitemapUnchecked: false } };
185
+ ok(`ru: ${n} ${word}`, new RegExp(`Прочитано ${n} ${word} сайта`).test(note(audit)));
186
+ }
187
+ }
188
+
150
189
  // ---- движок в пакете это та же программа, что стоит на сайтах
151
190
  {
152
191
  const here = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');