@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 +1 -1
- package/src/index.mjs +1 -1
- package/src/render.mjs +40 -8
- package/src/test-overall.mjs +37 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
@@ -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
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
<div class="overall-
|
|
218
|
-
|
|
219
|
-
<div class="overall-
|
|
220
|
-
|
|
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;
|
package/src/test-overall.mjs
CHANGED
|
@@ -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');
|