@operstack/audit 0.17.2 → 0.18.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.17.2",
3
+ "version": "0.18.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/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  export { collect, computeScores, computeOverall, verifyScores, SCORE_AREAS } from './collect.mjs';
2
2
  // Единственная копия движка видимости. Сайты и актор зовут его отсюда, своих копий больше нет.
3
3
  export { checkVisibility, normaliseInput, parseRobots, agentVerdict, MESSAGES as VISIBILITY_MESSAGES, VISIBILITY_DEFAULTS } from './visibility.mjs';
4
- export { render, checkNarrative } from './render.mjs';
4
+ export { render, checkNarrative, overallSummary } from './render.mjs';
5
5
  export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18n.mjs';
6
6
  export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
7
7
  export { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from './foundation.mjs';
package/src/render.mjs CHANGED
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { computeScores, computeOverall } from './collect.mjs';
11
11
  import { localiseBasisNote, AREAS_RU } from './i18n.mjs';
12
+ import { MESSAGES as VISIBILITY_MESSAGES } from './visibility.mjs';
12
13
  import { softenHex } from './agency.mjs';
13
14
 
14
15
  const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -102,6 +103,38 @@ const ruPoints = (n) => { const a = Math.abs(n) % 100; const b = a % 10; if (a >
102
103
 
103
104
  const scoreClass = (n) => (n === null || n === undefined ? '' : n <= 3 ? 'low' : n <= 6 ? 'mid' : 'ok');
104
105
 
106
+ /**
107
+ * Балл видимости и всё, что про него надо сказать словами, одним объектом.
108
+ *
109
+ * Простым языком: это тот самый блок, который стоит на первой странице отчёта. Письмо печатает
110
+ * его же. Раньше письмо собирало свою формулировку и свою цифру, и человек получал на странице
111
+ * одно, в письме другое, а в PDF третье. Теперь текст ровно один и живёт здесь.
112
+ *
113
+ * Названия областей берутся по `id`, а не по готовой подписи из замера: замер мог пройти на
114
+ * английской странице, а отчёт уходить русскому покупателю, и тогда подпись пришла бы на чужом
115
+ * языке. Язык отчёта главнее языка замера.
116
+ *
117
+ * Возвращает null, если балла нет: движок отказывается мерить закрытые и приватные адреса, и в
118
+ * этом случае честнее не называть цифру вовсе, чем подставить ноль.
119
+ */
120
+ export function overallSummary(overall, { lang = 'en' } = {}) {
121
+ if (!overall || overall.score === null || overall.score === undefined) return null;
122
+ const key = lang === 'ru' ? 'ru' : 'en';
123
+ const L = LABELS[key];
124
+ const names = VISIBILITY_MESSAGES[key].area;
125
+ return {
126
+ label: L.overallLabel,
127
+ score: overall.score,
128
+ grade: (L.grades && L.grades[overall.grade]) || overall.grade,
129
+ note: L.overallNote(overall),
130
+ areas: (Array.isArray(overall.areas) ? overall.areas : []).map((x) => ({
131
+ id: x.id, label: (x.id && names[x.id]) || x.label, score: x.score, max: x.max,
132
+ })),
133
+ secondMeasure: L.secondMeasure,
134
+ secondMeasureFoot: L.scorecardFoot.replace(/<\/?strong>/g, ''),
135
+ };
136
+ }
137
+
105
138
  export function checkNarrative(audit) {
106
139
  const out = [];
107
140
  const walk = (v, path) => {
@@ -211,14 +244,13 @@ export function toHtml(audit, brand = null) {
211
244
  * независимое измерение по проверкам самого отчёта, а не разбивка заголовка. Сводить их с
212
245
  * заголовком не нужно и не получится, и об этом сказано словами.
213
246
  */
214
- const overall = a.overall && a.overall.score !== undefined ? a.overall : null;
215
- const overallAreas = overall && Array.isArray(overall.areas) ? overall.areas : [];
216
- const overallBlock = !overall || overall.score === null ? '' : `<div class="overall">
217
- <div class="overall-num ${scoreClass(Math.round(overall.score / 10))}">${overall.score}<span>/100</span></div>
218
- <div class="overall-side"><div class="overall-label">${esc(L.overallLabel)}</div>
219
- <div class="overall-grade">${esc((L.grades && L.grades[overall.grade]) || overall.grade)}</div>
220
- <div class="overall-note">${esc(L.overallNote(overall))}</div></div></div>`
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>` : '');
247
+ const head = overallSummary(a.overall, { lang: a.meta?.lang === 'ru' ? 'ru' : 'en' });
248
+ const overallBlock = !head ? '' : `<div class="overall">
249
+ <div class="overall-num ${scoreClass(Math.round(head.score / 10))}">${head.score}<span>/100</span></div>
250
+ <div class="overall-side"><div class="overall-label">${esc(head.label)}</div>
251
+ <div class="overall-grade">${esc(head.grade)}</div>
252
+ <div class="overall-note">${esc(head.note)}</div></div></div>`
253
+ + (head.areas.length ? `<table class="areas"><tr>${head.areas.map((x) => `<th>${esc(x.label)}</th>`).join('')}</tr><tr>${head.areas.map((x) => `<td>${x.score}<span>/${x.max}</span></td>`).join('')}</tr></table>` : '');
222
254
  const scoreCards = Object.entries(measured).map(([label, n]) => {
223
255
  const basis = scoreBasis[label] || {};
224
256
  const shown = (L.areas && L.areas[label]) || label;
@@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs';
12
12
  import { createServer } from 'node:http';
13
13
  import { collect, computeOverall, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
14
14
  import { checkVisibility, VISIBILITY_DEFAULTS } from './visibility.mjs';
15
- import { toHtml } from './render.mjs';
15
+ import { toHtml, overallSummary } from './render.mjs';
16
16
 
17
17
  let bad = 0;
18
18
  const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
@@ -186,6 +186,42 @@ ok('параметры нельзя поменять на ходу', Object.isFr
186
186
  }
187
187
  }
188
188
 
189
+ // ---- один блок «балл и области» на отчёт и на письмо
190
+ //
191
+ // Письмо собирало свою формулировку и свою цифру, отчёт свою. Разошлись. Теперь обе стороны
192
+ // зовут overallSummary, и разойтись нечему: текст ровно один и живёт в пакете.
193
+ {
194
+ const areas = [{ id: 'access', label: 'Can AI crawlers read it', score: 20, max: 25 },
195
+ { id: 'index', label: 'Is there a map for agents (llms.txt)', score: 5, max: 15 },
196
+ { id: 'entity', label: 'Is the entity clear (schema)', score: 12, max: 20 },
197
+ { id: 'content', label: 'Is there something to quote', score: 18, max: 25 },
198
+ { id: 'trust', label: 'Can it be dated and trusted', score: 8, max: 15 }];
199
+ const overall = { score: 63, grade: 'B', areas, source: 'visibility:reused', measuredAt: '2026-09-14T20:00:00.000Z', basis: { pages: 4, sitemapRead: true, sitemapUnchecked: false } };
200
+
201
+ const ru = overallSummary(overall, { lang: 'ru' });
202
+ const en = overallSummary(overall, { lang: 'en' });
203
+ is('заголовок письма это заголовок отчёта', ru.score, 63);
204
+ is('сумма пяти областей даёт заголовок', ru.areas.reduce((a, x) => a + x.score, 0), ru.score);
205
+ ok('оценка словом переведена', ru.grade === 'Рабочее состояние' && en.grade === 'Workable');
206
+ const fixture = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
207
+ fixture.meta.lang = 'ru'; fixture.overall = overall;
208
+ ok('подпись та же, что печатает отчёт', ru.note === (toHtml(fixture, null).match(/overall-note">([^<]*)</) || [])[1]);
209
+
210
+ // Замер мог пройти на английской странице, а отчёт уходит русскому покупателю: язык отчёта главнее.
211
+ ok('названия областей на языке отчёта, а не замера', ru.areas[0].label === 'Могут ли роботы ИИ прочитать сайт');
212
+ ok('и в английском они английские', en.areas[0].label === 'Can AI crawlers read it');
213
+ ok('в русском блоке нет английских названий', !ru.areas.some((x) => /[A-Za-z]{4}/.test(x.label.replace('llms.txt', ''))));
214
+
215
+ // Шесть областей отчёта это второе измерение, и подпись обязана это говорить в обоих языках.
216
+ ok('подзаголовок второго измерения есть', /Второе, отдельное измерение/.test(ru.secondMeasure) && /second, separate measurement/.test(en.secondMeasure));
217
+ ok('и сказано, что складывать их с заголовком не надо', /в сумме его не дают/.test(ru.secondMeasureFoot) && /will not add up to it/.test(en.secondMeasureFoot));
218
+ ok('в подписи второго измерения нет разметки', !/</.test(ru.secondMeasureFoot) && !/</.test(en.secondMeasureFoot));
219
+
220
+ // Балла может не быть: движок не мерит закрытые адреса. Тогда письмо не называет цифру вовсе.
221
+ is('без балла блока нет', overallSummary({ score: null, grade: 'not measured', areas: [] }, { lang: 'ru' }), null);
222
+ is('и на пустом объекте тоже', overallSummary(null, { lang: 'en' }), null);
223
+ }
224
+
189
225
  // ---- движок в пакете это та же программа, что стоит на сайтах
190
226
  {
191
227
  const here = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');