@operstack/audit 0.17.0 → 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 +1 -1
- package/src/collect.mjs +26 -6
- package/src/render.mjs +22 -3
- package/src/test-overall.mjs +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.17.
|
|
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
|
@@ -207,7 +207,7 @@ async function readSitemap(startUrl, { maxFiles = 6, budgetMs = 45000, maxUrls =
|
|
|
207
207
|
|
|
208
208
|
export const __readSitemap = readSitemap;
|
|
209
209
|
|
|
210
|
-
export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3, preparedBy = '', requestTimeout = 15000, sitemapBudgetMs = 45000, sitemapMaxFiles = 6 } = {}) {
|
|
210
|
+
export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en', rendered = false, renderedPages = 3, preparedBy = '', visibility: givenVisibility = null, requestTimeout = 15000, sitemapBudgetMs = 45000, sitemapMaxFiles = 6 } = {}) {
|
|
211
211
|
REQUEST_TIMEOUT = requestTimeout;
|
|
212
212
|
const opts = { backlinks };
|
|
213
213
|
const origin = new URL(startUrl).origin;
|
|
@@ -483,11 +483,31 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
483
483
|
* тут больше страниц или времени, и число снова разойдётся со страницей. Глубина аудита живёт в
|
|
484
484
|
* его собственных шести областях и в списке задач, а не в этом числе.
|
|
485
485
|
*/
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
486
|
+
/*
|
|
487
|
+
* Если балл уже измерен, второй раз не меряем.
|
|
488
|
+
*
|
|
489
|
+
* Живая сверка 14.09.2026 на whitewill.ru дала 47 и 44 в двух прогонах подряд, а на habr.com
|
|
490
|
+
* один прогон вернул отказ. Код один и тот же, разная только удача сети: бюджет у проверки
|
|
491
|
+
* 8,5 секунды, и на медленном сайте карта сайта то успевает прочитаться, то нет, а движок
|
|
492
|
+
* честно не штрафует за непрочитанное. Два измерения одного сайта дают два числа, и покупателю
|
|
493
|
+
* всё равно, что оба честные: он видит 44 на странице и 47 в письме.
|
|
494
|
+
*
|
|
495
|
+
* Поэтому в воронке балл измеряется РОВНО ОДИН РАЗ, на странице, и приезжает сюда готовым
|
|
496
|
+
* через опцию `visibility`. Тогда страница, письмо и отчёт несут одно число по построению,
|
|
497
|
+
* а не по совпадению. Меряем сами только когда аудит запускают отдельно и мерить больше некому.
|
|
498
|
+
*/
|
|
499
|
+
let visibility = givenVisibility;
|
|
500
|
+
if (visibility) log('visibility score: taken from the check the visitor already ran');
|
|
501
|
+
else { log('visibility score'); visibility = await checkVisibility(startUrl, { ...VISIBILITY_DEFAULTS, lang }); }
|
|
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;
|
|
508
|
+
const overall = visibility && visibility.ok
|
|
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' };
|
|
491
511
|
// Шесть областей отчёта остаются, но это другое измерение, а не разбивка общего балла.
|
|
492
512
|
const reportScore = computeOverall(checks);
|
|
493
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
|
-
|
|
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:
|
|
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] || {};
|
package/src/test-overall.mjs
CHANGED
|
@@ -107,6 +107,85 @@ ok('параметры нельзя поменять на ходу', Object.isFr
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/*
|
|
111
|
+
* Балл, уже измеренный на странице, приезжает в отчёт готовым и не перемеряется.
|
|
112
|
+
*
|
|
113
|
+
* Живая сверка 14.09.2026: whitewill.ru дал 47 и 44 в двух прогонах подряд, habr.com в одном
|
|
114
|
+
* прогоне вернул отказ. Тот же код, разная удача сети: бюджет 8,5 секунды, и карта сайта то
|
|
115
|
+
* успевает прочитаться, то нет. Покупателю всё равно, что оба числа честные, он видит два разных.
|
|
116
|
+
* Поэтому в воронке измерение происходит один раз.
|
|
117
|
+
*/
|
|
118
|
+
{
|
|
119
|
+
const server = createServer((req, res) => {
|
|
120
|
+
res.setHeader('content-type', 'text/html');
|
|
121
|
+
res.end('<!doctype html><html><head><title>Local</title></head><body><h1>Local</h1><p>Text.</p></body></html>');
|
|
122
|
+
});
|
|
123
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
124
|
+
const base = `http://127.0.0.1:${server.address().port}/`;
|
|
125
|
+
|
|
126
|
+
const fromPage = { ok: true, score: 63, grade: 'B', checkedAt: '2026-09-14T20:00:00.000Z', ms: 4200, areas: [
|
|
127
|
+
{ id: 'access', label: 'Access for AI crawlers', score: 25, max: 25 },
|
|
128
|
+
{ id: 'index', label: 'Agent index', score: 10, max: 15 },
|
|
129
|
+
{ id: 'entity', label: 'Entity and structure', score: 12, max: 20 },
|
|
130
|
+
{ id: 'content', label: 'Answer-first content', score: 11, max: 25 },
|
|
131
|
+
{ id: 'trust', label: 'Freshness and sources', score: 5, max: 15 }] };
|
|
132
|
+
|
|
133
|
+
const audit = await collect(base, { pages: 2, log: () => {}, visibility: fromPage });
|
|
134
|
+
server.close();
|
|
135
|
+
|
|
136
|
+
is('готовый балл берётся как есть', audit.overall.score, 63);
|
|
137
|
+
is('и оценка буквой тоже', audit.overall.grade, 'B');
|
|
138
|
+
is('видно, что балл переиспользован, а не измерен заново', audit.overall.source, 'visibility:reused');
|
|
139
|
+
is('сохранено, когда его измерили', audit.overall.measuredAt, '2026-09-14T20:00:00.000Z');
|
|
140
|
+
is('пять областей приехали целиком', audit.overall.areas.length, 5);
|
|
141
|
+
is('и складываются в заголовок', audit.overall.areas.reduce((a, x) => a + x.score, 0), 63);
|
|
142
|
+
is('отчёт сходится сам с собой', verifyScores(audit), []);
|
|
143
|
+
// Локальный адрес движок бы мерить отказался, а балл всё равно есть: значит второго измерения не было.
|
|
144
|
+
ok('второго измерения не случилось', audit.overall.score === 63 && !audit.overall.error);
|
|
145
|
+
|
|
146
|
+
audit.meta.lang = 'ru';
|
|
147
|
+
is('и отчёт печатает именно его', Number((toHtml(audit, null).match(/overall-num[^>]*>(\d+)<span>/) || [])[1]), 63);
|
|
148
|
+
}
|
|
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
|
+
|
|
110
189
|
// ---- движок в пакете это та же программа, что стоит на сайтах
|
|
111
190
|
{
|
|
112
191
|
const here = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');
|