@operstack/audit 0.18.1 → 0.19.1
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 +9 -4
- package/src/render.mjs +17 -4
- package/src/test-blocked.mjs +84 -0
- package/src/test-unmeasured.mjs +132 -0
- package/src/visibility.mjs +104 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
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",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"LICENSE"
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
|
-
"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-rendered.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",
|
|
31
|
+
"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-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",
|
|
32
32
|
"sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
|
|
33
33
|
"verify:live": "node src/verify-live.mjs"
|
|
34
34
|
},
|
package/src/collect.mjs
CHANGED
|
@@ -659,10 +659,15 @@ export function verifyScores(audit) {
|
|
|
659
659
|
* давать в сумме ровно заголовок. Ровно это и защищает от числа, взятого с потолка.
|
|
660
660
|
*/
|
|
661
661
|
if (audit.overall && audit.overall.score !== undefined && audit.overall.score !== null && Array.isArray(audit.overall.areas) && audit.overall.areas.length) {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const
|
|
665
|
-
|
|
662
|
+
// Неизмеренная область (robots.txt не прочитался) из суммы выпадает, а балл переносится на
|
|
663
|
+
// сто по измеренному. Сходимость проверяется той же арифметикой, что его посчитала.
|
|
664
|
+
const measured = audit.overall.areas.filter((x) => x.measured !== false && x.score !== null);
|
|
665
|
+
const sum = measured.reduce((acc, x) => acc + (Number(x.score) || 0), 0);
|
|
666
|
+
const max = measured.reduce((acc, x) => acc + (Number(x.max) || 0), 0);
|
|
667
|
+
const expected = max === 100 ? sum : (max > 0 ? Math.round((sum / max) * 100) : null);
|
|
668
|
+
if (expected !== audit.overall.score) problems.push(`overall: the report says ${show100(audit.overall.score)}, its own measured areas give ${expected === null ? 'nothing' : show100(expected)} (${sum} of ${max})`);
|
|
669
|
+
const all = audit.overall.areas.reduce((acc, x) => acc + (Number(x.max) || 0), 0);
|
|
670
|
+
if (all !== 100) problems.push(`overall: the five areas are out of ${all}, not 100`);
|
|
666
671
|
}
|
|
667
672
|
|
|
668
673
|
// Балл по проверкам самого отчёта это отдельное измерение, и он пересчитывается из checks.
|
package/src/render.mjs
CHANGED
|
@@ -37,7 +37,12 @@ const LABELS = {
|
|
|
37
37
|
const where = o.source === 'visibility:reused'
|
|
38
38
|
? `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. `
|
|
39
39
|
: 'Measured here by the same code and the same settings as the free check at oper-stack.com/ai-visibility. ';
|
|
40
|
-
|
|
40
|
+
const areas = Array.isArray(o.areas) ? o.areas : [];
|
|
41
|
+
const measured = areas.filter((a) => a.measured !== false && a.score !== null);
|
|
42
|
+
const sumUp = areas.length && measured.length < areas.length
|
|
43
|
+
? `Of the five areas below, ${measured.length} could be measured; they add up to ${measured.reduce((t, a) => t + a.score, 0)} out of ${measured.reduce((t, a) => t + a.max, 0)}, and the score is that same share carried to 100. An area marked not measured is not counted for or against the site. `
|
|
44
|
+
: 'The five areas below add up to it exactly, so you can recompute it by hand. ';
|
|
45
|
+
return `${where}${read}${sumUp}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.`;
|
|
41
46
|
},
|
|
42
47
|
secondMeasure: 'A second, separate measurement: how this report scores its own checks',
|
|
43
48
|
secOverview: 'Site overview', overviewEyebrow: '02 · Overview', whatSiteIs: 'What the site is', parameter: 'Parameter', value: 'Value', pagesSampled: 'Pages sampled', colUrl: 'URL', colTitle: 'Title', colWords: 'Words',
|
|
@@ -72,7 +77,12 @@ const LABELS = {
|
|
|
72
77
|
const where = o.source === 'visibility:reused'
|
|
73
78
|
? `Это ровно то число, которое вы уже видели в бесплатной проверке${o.measuredAt ? ` ${String(o.measuredAt).slice(0, 10)}` : ''}. Здесь оно не меряется заново, поэтому страница, письмо и этот отчёт всегда несут одно число. `
|
|
74
79
|
: 'Измерено здесь тем же кодом и с теми же настройками, что у бесплатной проверки на oper-stack.com/ai-visibility. ';
|
|
75
|
-
|
|
80
|
+
const areas = Array.isArray(o.areas) ? o.areas : [];
|
|
81
|
+
const measured = areas.filter((a) => a.measured !== false && a.score !== null);
|
|
82
|
+
const sumUp = areas.length && measured.length < areas.length
|
|
83
|
+
? `Из пяти областей ниже измерены ${measured.length}, они дают ${measured.reduce((t, a) => t + a.score, 0)} из ${measured.reduce((t, a) => t + a.max, 0)}, и балл это та же доля, перенесённая на сто. Область с пометкой «не измерялось» не зачтена ни в плюс, ни в минус. `
|
|
84
|
+
: 'Пять областей ниже дают в сумме ровно этот балл, его можно сложить руками. ';
|
|
85
|
+
return `${where}${read}${sumUp}Повторная проверка может дать на пункт-другой иначе: проверка, которая успела ответить в один прогон, может не успеть в следующий, а балл никогда не засчитывает сайту в минус то, что не удалось прочитать.`;
|
|
76
86
|
},
|
|
77
87
|
secondMeasure: 'Второе, отдельное измерение: как этот отчёт оценивает собственные проверки',
|
|
78
88
|
secOverview: 'О сайте', overviewEyebrow: '02 · Обзор', whatSiteIs: 'Что это за сайт', parameter: 'Параметр', value: 'Значение', pagesSampled: 'Проверенные страницы', colUrl: 'Адрес', colTitle: 'Заголовок', colWords: 'Слов',
|
|
@@ -128,8 +138,11 @@ export function overallSummary(overall, { lang = 'en' } = {}) {
|
|
|
128
138
|
grade: (L.grades && L.grades[overall.grade]) || overall.grade,
|
|
129
139
|
note: L.overallNote(overall),
|
|
130
140
|
areas: (Array.isArray(overall.areas) ? overall.areas : []).map((x) => ({
|
|
131
|
-
id: x.id, label: (x.id && names[x.id]) || x.label,
|
|
141
|
+
id: x.id, label: (x.id && names[x.id]) || x.label,
|
|
142
|
+
// Неизмеренная область несёт null, а не ноль: ноль это утверждение, что там плохо.
|
|
143
|
+
score: x.measured === false ? null : x.score, max: x.max, measured: x.measured !== false,
|
|
132
144
|
})),
|
|
145
|
+
notMeasured: L.notMeasured,
|
|
133
146
|
secondMeasure: L.secondMeasure,
|
|
134
147
|
secondMeasureFoot: L.scorecardFoot.replace(/<\/?strong>/g, ''),
|
|
135
148
|
};
|
|
@@ -250,7 +263,7 @@ export function toHtml(audit, brand = null) {
|
|
|
250
263
|
<div class="overall-side"><div class="overall-label">${esc(head.label)}</div>
|
|
251
264
|
<div class="overall-grade">${esc(head.grade)}</div>
|
|
252
265
|
<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
|
|
266
|
+
+ (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 === null ? `<span>${esc(head.notMeasured)}</span>` : `${x.score}<span>/${x.max}</span>`}</td>`).join('')}</tr></table>` : '');
|
|
254
267
|
const scoreCards = Object.entries(measured).map(([label, n]) => {
|
|
255
268
|
const basis = scoreBasis[label] || {};
|
|
256
269
|
const shown = (L.areas && L.areas[label]) || label;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Сайт нас не пустил. Что мы имеем право сказать про него владельцу.
|
|
4
|
+
*
|
|
5
|
+
* До 15.09.2026 проверка писала: «Роботы ИИ её не проходят, поэтому ChatGPT, Perplexity и
|
|
6
|
+
* остальных разворачивают ровно так же». Это вывод о ЧУЖОМ сайте, сделанный из того, что
|
|
7
|
+
* развернули НАС. Замерено руками на alternativeto.net: тот же адрес отдаёт GPTBot, ClaudeBot
|
|
8
|
+
* и PerplexityBot полную страницу (200, около 900 КБ), а нашей проверке 403. Защита держит
|
|
9
|
+
* белый список проверенных роботов.
|
|
10
|
+
*
|
|
11
|
+
* Здесь держится граница: говорим, что не пустили нас, и что написано в правилах сайта.
|
|
12
|
+
* Про поведение защиты не гадаем.
|
|
13
|
+
*/
|
|
14
|
+
import { blockedNote } from './visibility.mjs';
|
|
15
|
+
|
|
16
|
+
let bad = 0;
|
|
17
|
+
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
18
|
+
|
|
19
|
+
const OPEN = 'User-agent: *\nDisallow: /admin/\n';
|
|
20
|
+
const CLOSED = 'User-agent: GPTBot\nDisallow: /\n\nUser-agent: ClaudeBot\nDisallow: /\n';
|
|
21
|
+
|
|
22
|
+
// ---- главное: ни при каких вводных не утверждать, что роботов ИИ развернули
|
|
23
|
+
{
|
|
24
|
+
for (const lang of ['ru', 'en']) {
|
|
25
|
+
for (const [what, robots] of [['правила открыты', OPEN], ['правила закрыты', CLOSED], ['robots не прочитан', null]]) {
|
|
26
|
+
for (const challenged of [true, false]) {
|
|
27
|
+
const t = blockedNote({ status: 403, challenged, robotsText: robots, lang }).text;
|
|
28
|
+
ok(`${lang}/${what}/${challenged ? 'защита' : 'отказ'}: не утверждаем, что роботов ИИ развернули`, lang === 'ru'
|
|
29
|
+
? !/(разворачивают|не проходят|получают тот же ответ|процитировать (здесь|с сайта) нельзя|нечего)/.test(t.replace('сказать нечего', ''))
|
|
30
|
+
: !/(turned away|do not run one|get the same answer|nothing here can be read)/.test(t));
|
|
31
|
+
ok(`${lang}/${what}/${challenged ? 'защита' : 'отказ'}: сказано, как проверить точно`, lang === 'ru' ? /журнале сервера/.test(t) : /server log/.test(t));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- что именно говорим в каждом из трёх случаев
|
|
38
|
+
{
|
|
39
|
+
const ru = (robots) => blockedNote({ status: 403, challenged: true, robotsText: robots, lang: 'ru' });
|
|
40
|
+
const en = (robots) => blockedNote({ status: 403, challenged: true, robotsText: robots, lang: 'en' });
|
|
41
|
+
|
|
42
|
+
const open = ru(OPEN);
|
|
43
|
+
ok('правила открыты: сказано, что пускают', /правила самого сайта роботов ИИ при этом пускают/i.test(open.text));
|
|
44
|
+
ok('правила открыты: сказано, что не зачтено в минус', /не зачтено/.test(open.text));
|
|
45
|
+
ok('правила открыты: названы конкретные роботы', /OAI-SearchBot|GPTBot/.test(open.text));
|
|
46
|
+
ok('правила открыты: поле говорит «не закрыты»', open.botsBlocked === false);
|
|
47
|
+
|
|
48
|
+
const closed = ru(CLOSED);
|
|
49
|
+
ok('правила закрыты: названо письменным отказом', /письменный отказ/.test(closed.text));
|
|
50
|
+
ok('правила закрыты: названы именно закрытые', /GPTBot/.test(closed.text) && /Claude/.test(closed.text));
|
|
51
|
+
ok('правила закрыты: поле говорит «закрыты»', closed.botsBlocked === true);
|
|
52
|
+
|
|
53
|
+
const none = ru(null);
|
|
54
|
+
ok('robots не прочитан: молчим про роботов', /про роботов ИИ сказать нечего/.test(none.text));
|
|
55
|
+
ok('robots не прочитан: и это не в минус', /не зачтено/.test(none.text));
|
|
56
|
+
ok('robots не прочитан: поле пустое, а не false', none.botsBlocked === null);
|
|
57
|
+
|
|
58
|
+
ok('английский: правила открыты', /rules do allow the AI crawlers/.test(en(OPEN).text));
|
|
59
|
+
ok('английский: правила закрыты', /rules also close it to AI crawlers/.test(en(CLOSED).text));
|
|
60
|
+
ok('английский: robots не прочитан', /robots\.txt could not be read/.test(en(null).text));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- защита и простой отказ описываются по-разному: лечатся они тоже по-разному
|
|
64
|
+
{
|
|
65
|
+
const ch = blockedNote({ status: 403, challenged: true, robotsText: OPEN, lang: 'ru' }).text;
|
|
66
|
+
const wall = blockedNote({ status: 403, challenged: false, robotsText: OPEN, lang: 'ru' }).text;
|
|
67
|
+
ok('защита названа защитой браузера', /проверкой браузера \(Cloudflare/.test(ch));
|
|
68
|
+
ok('простой отказ назван отказом незнакомым клиентам', /отказывает незнакомым клиентам/.test(wall));
|
|
69
|
+
ok('код ответа назван в обоих', /403/.test(ch) && /403/.test(wall));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- чужого языка в тексте нет: его читает человек
|
|
73
|
+
{
|
|
74
|
+
const ruText = blockedNote({ status: 403, challenged: true, robotsText: OPEN, lang: 'ru' }).text;
|
|
75
|
+
// Имена роботов это их настоящие идентификаторы, они латиницей и на русском тоже: ChatGPT-User
|
|
76
|
+
// нельзя перевести, иначе владелец не найдёт его в журнале сервера. Вычёркиваем их и смотрим,
|
|
77
|
+
// не осталось ли непереведённых слов.
|
|
78
|
+
const bare = ruText.replace(/ChatGPT-User|OAI-SearchBot|Claude-SearchBot|Claude-User|Applebot-Extended|Google-Extended|anthropic-ai|PerplexityBot|Perplexity-User|Cloudflare|GPTBot|ClaudeBot|ChatGPT|OpenAI|Perplexity|Claude|robots\.txt|Anthropic|Gemini|Bingbot|Copilot|Bing|Apple Intelligence/g, '');
|
|
79
|
+
ok('в русском тексте не осталось английских слов', !/[A-Za-z]{4}/.test(bare));
|
|
80
|
+
ok('в английском нет кириллицы', !/[А-Яа-яЁё]/.test(blockedNote({ status: 403, challenged: true, robotsText: OPEN, lang: 'en' }).text));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
84
|
+
console.log('\nпро чужой сайт говорим только измеренное');
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Не прочитали robots.txt, значит не знаем, а не «всё отлично».
|
|
4
|
+
*
|
|
5
|
+
* 15.09.2026 проверка ставила 25 из 25 и писала «Все 14 роботов ИИ допущены на сайт» сайту, чей
|
|
6
|
+
* robots.txt отдал 403. Файла нет, значит запретов нет, значит отлично. Владелец получал высшую
|
|
7
|
+
* оценку за файл, который мы не открывали. Незнание это не хорошая новость.
|
|
8
|
+
*
|
|
9
|
+
* Здесь держится вся цепочка: движок помечает область неизмеренной и переносит балл на сто по
|
|
10
|
+
* остальным, проверка сходимости это понимает, отчёт печатает слово вместо числа и объясняет,
|
|
11
|
+
* из чего сложился балл.
|
|
12
|
+
*
|
|
13
|
+
* Сервер поднимается на loopback, а зовётся по имени lvh.me: оно публично указывает на 127.0.0.1,
|
|
14
|
+
* и так проверка проходит свой же запрет на частные адреса честно, без лазейки в коде продукта.
|
|
15
|
+
*/
|
|
16
|
+
import { createServer } from 'node:http';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { lookup } from 'node:dns/promises';
|
|
19
|
+
import { checkVisibility } from './visibility.mjs';
|
|
20
|
+
import { verifyScores } from './collect.mjs';
|
|
21
|
+
import { toHtml, overallSummary } from './render.mjs';
|
|
22
|
+
|
|
23
|
+
let bad = 0;
|
|
24
|
+
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
25
|
+
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
26
|
+
|
|
27
|
+
const PAGE = `<!doctype html><html lang="ru"><head><title>Пример</title><meta name="description" content="Описание"></head>
|
|
28
|
+
<body><main><h1>Пример сайта</h1>${'<p>Обычный абзац текста про товар и цену, без цифр, чтобы было что цитировать. </p>'.repeat(40)}</main></body></html>`;
|
|
29
|
+
|
|
30
|
+
const serve = ({ robots, status = null }) => new Promise((resolve) => {
|
|
31
|
+
const s = createServer((req, res) => {
|
|
32
|
+
if (req.url.startsWith('/robots.txt')) {
|
|
33
|
+
if (status) { res.writeHead(status, { 'content-type': 'text/html' }); res.end('<html>нет</html>'); return; }
|
|
34
|
+
if (robots === null) { res.writeHead(403, { 'content-type': 'text/html' }); res.end('<html>denied</html>'); return; }
|
|
35
|
+
res.writeHead(200, { 'content-type': 'text/plain' }); res.end(robots); return;
|
|
36
|
+
}
|
|
37
|
+
if (req.url === '/' ) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(PAGE); return; }
|
|
38
|
+
res.writeHead(404, { 'content-type': 'text/plain' }); res.end('no');
|
|
39
|
+
});
|
|
40
|
+
s.listen(0, '127.0.0.1', () => resolve({ s, url: `http://lvh.me:${s.address().port}/` }));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
let dnsOk = true;
|
|
44
|
+
try { const a = await lookup('lvh.me'); dnsOk = a.address === '127.0.0.1'; } catch { dnsOk = false; }
|
|
45
|
+
|
|
46
|
+
if (!dnsOk) {
|
|
47
|
+
console.log('ПРОПУЩЕНО: lvh.me не указывает на 127.0.0.1 с этой машины, сквозная часть не прогнана');
|
|
48
|
+
} else {
|
|
49
|
+
// ---- robots.txt закрыт: область не измерена, балл по остальным
|
|
50
|
+
const { s, url } = await serve({ robots: null });
|
|
51
|
+
let v;
|
|
52
|
+
try { v = await checkVisibility(url, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s.close(); }
|
|
53
|
+
ok('проверка прошла (главная читается)', v.ok === true);
|
|
54
|
+
const access = v.areas.find((a) => a.id === 'access');
|
|
55
|
+
is('область доступа помечена неизмеренной', access.measured, false);
|
|
56
|
+
is('и балла у неё нет, а не ноль и не 25', access.score, null);
|
|
57
|
+
ok('первая находка говорит, что файл не прочитан', /не дал этой проверке прочитать robots\.txt/.test(access.findings[0].text));
|
|
58
|
+
ok('и что это не хорошая новость', /не хорошая новость/.test(access.findings[0].text));
|
|
59
|
+
ok('и не пишет «все роботы допущены»', !access.findings.some((f) => /допущены на сайт/.test(f.text)));
|
|
60
|
+
const rest = v.areas.filter((a) => a.id !== 'access');
|
|
61
|
+
const sum = rest.reduce((t, a) => t + a.score, 0);
|
|
62
|
+
const max = rest.reduce((t, a) => t + a.max, 0);
|
|
63
|
+
is('остальные области считаются из 75', max, 75);
|
|
64
|
+
is('балл это доля измеренного, перенесённая на сто', v.score, Math.round((sum / max) * 100));
|
|
65
|
+
ok('в исправлениях нет пункта про непрочитанный файл', !v.fixes.some((f) => f.id === 'robots-unreadable'));
|
|
66
|
+
|
|
67
|
+
// ---- сходимость: проверка отчёта понимает неизмеренную область
|
|
68
|
+
const problems = verifyScores({ checks: [], scores: {}, overall: { score: v.score, grade: v.grade, areas: v.areas } }).filter((p) => p.startsWith('overall:'));
|
|
69
|
+
is('проверка сходимости не спорит с движком', problems, []);
|
|
70
|
+
|
|
71
|
+
// ---- отчёт: слово вместо числа и честная подпись
|
|
72
|
+
const fixture = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
73
|
+
fixture.meta.lang = 'ru';
|
|
74
|
+
fixture.overall = { score: v.score, grade: v.grade, areas: v.areas, source: 'visibility', basis: { pages: 1, sitemapRead: false, sitemapUnchecked: false } };
|
|
75
|
+
const html = toHtml(fixture, null);
|
|
76
|
+
ok('в таблице областей стоит слово, а не null', /<td><span>не измерялось<\/span><\/td>/.test(html) && !/null/.test(html.match(/<table class="areas">[\s\S]*?<\/table>/)[0]));
|
|
77
|
+
const note = (html.match(/overall-note">([^<]*)</) || [])[1] || '';
|
|
78
|
+
ok('подпись говорит, сколько областей измерено', /Из пяти областей ниже измерены 4/.test(note));
|
|
79
|
+
ok('и из скольких очков', new RegExp(`${sum} из 75`).test(note));
|
|
80
|
+
ok('и что неизмеренное не в минус', /не зачтена ни в плюс, ни в минус/.test(note));
|
|
81
|
+
ok('старой фразы «пять областей дают в сумме ровно» нет', !/дают в сумме ровно/.test(note));
|
|
82
|
+
|
|
83
|
+
fixture.meta.lang = 'en';
|
|
84
|
+
const en = (toHtml(fixture, null).match(/overall-note">([^<]*)</) || [])[1] || '';
|
|
85
|
+
ok('английская подпись тоже честная', /4 could be measured/.test(en) && /not counted for or against/.test(en));
|
|
86
|
+
|
|
87
|
+
// ---- письмо берёт тот же блок: null остаётся null, слово едет рядом
|
|
88
|
+
const head = overallSummary(fixture.overall, { lang: 'ru' });
|
|
89
|
+
is('в блоке для письма область без балла', head.areas.find((a) => a.id === 'access').score, null);
|
|
90
|
+
is('и слово для неё передано', head.notMeasured, 'не измерялось');
|
|
91
|
+
|
|
92
|
+
// ---- контроль: с прочитанным robots.txt всё как раньше
|
|
93
|
+
const { s: s2, url: url2 } = await serve({ robots: 'User-agent: *\nDisallow: /admin/\n' });
|
|
94
|
+
let v2;
|
|
95
|
+
try { v2 = await checkVisibility(url2, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s2.close(); }
|
|
96
|
+
const access2 = v2.areas.find((a) => a.id === 'access');
|
|
97
|
+
is('с прочитанным файлом область измерена', access2.measured, true);
|
|
98
|
+
is('и оценена полностью', access2.score, 25);
|
|
99
|
+
is('и балл это прямая сумма', v2.score, v2.areas.reduce((t, a) => t + a.score, 0));
|
|
100
|
+
ok('подпись в отчёте прежняя', /дают в сумме ровно/.test((toHtml({ ...fixture, meta: { ...fixture.meta, lang: 'ru' }, overall: { ...fixture.overall, score: v2.score, areas: v2.areas } }, null).match(/overall-note">([^<]*)</) || [])[1] || ''));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- файла нет это ОТВЕТ, а не незнание
|
|
104
|
+
//
|
|
105
|
+
// Первая версия правки считала неизмеренным любой неуспех, и сайт без robots.txt терял 25
|
|
106
|
+
// заслуженных баллов: по стандарту отсутствие файла означает, что не запрещено ничего.
|
|
107
|
+
// Поймано на живом example.com сразу после выкладки: балл упал с 30 до 7.
|
|
108
|
+
for (const code of [404, 410]) {
|
|
109
|
+
const { s: s3, url: url3 } = await serve({ status: code });
|
|
110
|
+
let v3;
|
|
111
|
+
try { v3 = await checkVisibility(url3, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s3.close(); }
|
|
112
|
+
const a3 = v3.areas.find((a) => a.id === 'access');
|
|
113
|
+
is(`robots.txt ${code}: область измерена`, a3.measured, true);
|
|
114
|
+
is(`robots.txt ${code}: и оценена полностью`, a3.score, 25);
|
|
115
|
+
ok(`robots.txt ${code}: сказано, что файла нет`, /Файла robots\.txt на сайте нет/.test(a3.findings[0].text));
|
|
116
|
+
ok(`robots.txt ${code}: и что это значит «не запрещено ничего»`, /не запрещено ничего/.test(a3.findings[0].text));
|
|
117
|
+
ok(`robots.txt ${code}: не назвали это непрочитанным`, !a3.findings.some((f) => f.id === 'robots-unreadable'));
|
|
118
|
+
is(`robots.txt ${code}: балл это прямая сумма`, v3.score, v3.areas.reduce((t, a) => t + a.score, 0));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- а отказ и сбой это по-прежнему незнание
|
|
122
|
+
for (const code of [401, 403, 429, 500]) {
|
|
123
|
+
const { s: s4, url: url4 } = await serve({ status: code });
|
|
124
|
+
let v4;
|
|
125
|
+
try { v4 = await checkVisibility(url4, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s4.close(); }
|
|
126
|
+
const a4 = v4.areas.find((a) => a.id === 'access');
|
|
127
|
+
is(`robots.txt ${code}: область не измерена`, a4.measured, false);
|
|
128
|
+
ok(`robots.txt ${code}: код назван в тексте`, new RegExp(`код ${code}`).test(a4.findings[0].text));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
132
|
+
console.log('\nнепрочитанное не считается ни в плюс, ни в минус, а отсутствующее это ответ');
|
package/src/visibility.mjs
CHANGED
|
@@ -49,9 +49,17 @@ export const MESSAGES = {
|
|
|
49
49
|
badInput: 'Enter a public site address, for example example.com',
|
|
50
50
|
tooSlow: 'The site took too long to answer',
|
|
51
51
|
badAnswer: (status, url) => `The site answered ${status || 'nothing'} for ${url}`,
|
|
52
|
-
botWall: (status) => `The site refuses unknown clients (HTTP ${status})
|
|
53
|
-
botChallenge: (status) => `The site sits behind a browser challenge (Cloudflare, HTTP ${status})
|
|
52
|
+
botWall: (status) => `The site refuses unknown clients (HTTP ${status}), so this check could not read a single page.`,
|
|
53
|
+
botChallenge: (status) => `The site sits behind a browser challenge (Cloudflare, HTTP ${status}), so this check could not read a single page.`,
|
|
54
|
+
// Что мы знаем про роботов ИИ, когда нас самих развернули: ровно то, что написано в правилах
|
|
55
|
+
// сайта, и ни словом больше. Пускают ли их на самом деле, решает защита, а не мы.
|
|
56
|
+
rulesAllowBots: (list) => ` The site's own rules do allow the AI crawlers (${list}), and sites behind this kind of protection often do let the verified ones through. Whether they get in is decided by the protection, not by this check, so nothing here is counted for or against the site.`,
|
|
57
|
+
rulesBlockBots: (list) => ` The site's own rules also close it to AI crawlers (${list}), so on top of this wall there is a written refusal.`,
|
|
58
|
+
rulesUnknown: ' Its robots.txt could not be read either, so there is nothing to say about AI crawlers: that is not counted for or against the site.',
|
|
59
|
+
howToCheckBots: ' To know for certain, open the server log and look for GPTBot, ClaudeBot and PerplexityBot, or ask whoever runs the protection which bots are on the allowlist.',
|
|
54
60
|
area: { access: 'Can AI crawlers read it', index: 'Is there a map for agents (llms.txt)', entity: 'Is the entity clear (schema)', content: 'Is there something to quote', trust: 'Can it be dated and trusted' },
|
|
61
|
+
robotsMissing: 'There is no robots.txt on the site. By the standard that means nothing is disallowed, so every AI crawler may read it. A file is not required; add one only when something needs closing off.',
|
|
62
|
+
robotsUnreadable: (status) => `The site did not let this check read robots.txt${status ? ` (HTTP ${status})` : ''}, so whether AI crawlers are allowed could not be measured. It is not counted for or against the site: an unread file is not good news. The owner can open it in a browser, or ask whoever runs the site's protection.`,
|
|
55
63
|
robotsAllBlocked: 'robots.txt disallows the whole site for every crawler. Nothing can read it.',
|
|
56
64
|
fetchersBlocked: (list) => `Blocked answer-engine fetchers: ${list}. These are the bots that cite pages live.`,
|
|
57
65
|
trainingBlocked: (list) => `Blocked training crawlers: ${list}. Models will not learn the brand from the site.`,
|
|
@@ -90,9 +98,15 @@ export const MESSAGES = {
|
|
|
90
98
|
badInput: 'Введите адрес публичного сайта, например example.ru',
|
|
91
99
|
tooSlow: 'Сайт слишком долго не отвечал',
|
|
92
100
|
badAnswer: (status, url) => `Сайт ответил ${status ? `кодом ${status}` : 'ничем'} на ${url}`,
|
|
93
|
-
botWall: (status) => `Сайт отказывает незнакомым клиентам (код ${status})
|
|
94
|
-
botChallenge: (status) => `Сайт закрыт проверкой браузера (Cloudflare, код ${status})
|
|
101
|
+
botWall: (status) => `Сайт отказывает незнакомым клиентам (код ${status}), поэтому прочитать хотя бы одну страницу проверка не смогла.`,
|
|
102
|
+
botChallenge: (status) => `Сайт закрыт проверкой браузера (Cloudflare, код ${status}), поэтому прочитать хотя бы одну страницу проверка не смогла.`,
|
|
103
|
+
rulesAllowBots: (list) => ` Правила самого сайта роботов ИИ при этом пускают (${list}), а защита такого типа проверенных роботов нередко пропускает. Пустят ли их на деле, решает защита, а не эта проверка, поэтому в плюс или в минус сайту это не зачтено.`,
|
|
104
|
+
rulesBlockBots: (list) => ` Правила самого сайта роботов ИИ тоже закрывают (${list}), то есть к этой стене добавлен ещё и письменный отказ.`,
|
|
105
|
+
rulesUnknown: ' Файл robots.txt прочитать тоже не вышло, поэтому про роботов ИИ сказать нечего, и в плюс или в минус сайту это не зачтено.',
|
|
106
|
+
howToCheckBots: ' Чтобы знать точно, посмотрите в журнале сервера, приходят ли GPTBot, ClaudeBot и PerplexityBot, либо спросите у тех, кто настраивал защиту, кто у неё в белом списке.',
|
|
95
107
|
area: { access: 'Могут ли роботы ИИ прочитать сайт', index: 'Есть ли карта для агентов (llms.txt)', entity: 'Понятно ли, кто вы (разметка)', content: 'Есть ли что процитировать', trust: 'Можно ли датировать и доверять' },
|
|
108
|
+
robotsMissing: 'Файла robots.txt на сайте нет. По стандарту это значит, что не запрещено ничего, то есть читать сайт может любой робот ИИ. Заводить файл необязательно: он нужен, только когда есть что закрывать.',
|
|
109
|
+
robotsUnreadable: (status) => `Сайт не дал этой проверке прочитать robots.txt${status ? ` (код ${status})` : ''}, поэтому допущены роботы ИИ или нет, измерить не вышло. В плюс или в минус сайту это не зачтено: непрочитанный файл это не хорошая новость. Владелец может открыть его в браузере сам или спросить у тех, кто настраивал защиту сайта.`,
|
|
96
110
|
robotsAllBlocked: 'robots.txt закрывает весь сайт для всех роботов. Его никто не может прочитать.',
|
|
97
111
|
fetchersBlocked: (list) => `Закрыты поисковые роботы ответных систем: ${list}. Именно они достают страницу, чтобы процитировать её в ответе.`,
|
|
98
112
|
trainingBlocked: (list) => `Закрыты обучающие роботы: ${list}. Модели не узнают о бренде с сайта.`,
|
|
@@ -325,6 +339,33 @@ async function readSitemap(origin, robotsText, timeoutFn = () => 3000) {
|
|
|
325
339
|
*/
|
|
326
340
|
export const VISIBILITY_DEFAULTS = Object.freeze({ budgetMs: 8500, samplePages: 3 });
|
|
327
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Что писать владельцу сайта, который нас не пустил.
|
|
344
|
+
*
|
|
345
|
+
* Отдельной функцией намеренно: это самый ответственный текст всей проверки. Он уходит человеку
|
|
346
|
+
* про его собственный сайт, и до 15.09.2026 он врал. Стояло «роботы ИИ её не проходят, поэтому
|
|
347
|
+
* ChatGPT, Perplexity и остальных разворачивают ровно так же». Это вывод о чужом сайте из того,
|
|
348
|
+
* что развернули НАС. Замерено руками на alternativeto.net: тот же адрес отдаёт GPTBot, ClaudeBot
|
|
349
|
+
* и PerplexityBot полную страницу (200, около 900 КБ), а нашей проверке 403. Защита такого типа
|
|
350
|
+
* держит белый список проверенных роботов.
|
|
351
|
+
*
|
|
352
|
+
* Граница проходит здесь: мы знаем, что не пустили нас, и знаем, что написано в robots.txt (его
|
|
353
|
+
* отдают и тогда, когда страницы закрыты). Поведение защиты по отношению к чужим роботам мы не
|
|
354
|
+
* измеряли и утверждать про него не имеем права.
|
|
355
|
+
*/
|
|
356
|
+
export function blockedNote({ status, challenged, blocked = true, robotsText = null, lang = 'en' } = {}) {
|
|
357
|
+
const T = MESSAGES[lang] || MESSAGES.en;
|
|
358
|
+
const head = challenged ? T.botChallenge(status) : T.botWall(status);
|
|
359
|
+
if (!blocked) return { text: head, botsBlocked: null };
|
|
360
|
+
const rules = robotsText ? parseRobots(robotsText) : null;
|
|
361
|
+
if (!rules) return { text: head + T.rulesUnknown + T.howToCheckBots, botsBlocked: null };
|
|
362
|
+
const closed = AI_AGENTS.filter((a) => agentVerdict(rules, a.agent) === 'blocked');
|
|
363
|
+
const names = (list) => list.slice(0, 3).map((a) => (lang === 'ru' ? a.labelRu : a.label)).join(', ')
|
|
364
|
+
+ (list.length > 3 ? (lang === 'ru' ? ` и ещё ${list.length - 3}` : `, and ${list.length - 3} more`) : '');
|
|
365
|
+
const tail = closed.length ? T.rulesBlockBots(names(closed)) : T.rulesAllowBots(names(AI_AGENTS));
|
|
366
|
+
return { text: head + tail + T.howToCheckBots, botsBlocked: closed.length > 0 };
|
|
367
|
+
}
|
|
368
|
+
|
|
328
369
|
export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', samplePages = 3 } = {}) {
|
|
329
370
|
const T = MESSAGES[lang] || MESSAGES.en;
|
|
330
371
|
const agentLabel = (a) => (lang === 'ru' ? a.labelRu : a.label);
|
|
@@ -343,11 +384,28 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
|
|
|
343
384
|
// её не проходит, поэтому сайт за ней невидим для всех ассистентов, что бы ни было на
|
|
344
385
|
// страницах. Пришло из актора Apify, где это работало, а на сайтах человек видел «403».
|
|
345
386
|
const challenged = blocked && (/challenge|managed/i.test(home.mitigated) || (/cloudflare/i.test(home.server) && home.status === 403));
|
|
387
|
+
|
|
388
|
+
/*
|
|
389
|
+
* Чего мы НЕ знаем, когда нас развернули.
|
|
390
|
+
*
|
|
391
|
+
* До 15.09.2026 здесь стояло «роботы ИИ её не проходят, поэтому ChatGPT, Perplexity и
|
|
392
|
+
* остальных разворачивают ровно так же». Это вывод о чужом сайте, сделанный из того, что
|
|
393
|
+
* развернули НАС. Проверено руками на alternativeto.net: один и тот же адрес отдаёт GPTBot,
|
|
394
|
+
* ClaudeBot и PerplexityBot полную страницу (200, около 900 КБ), а нашей проверке 403.
|
|
395
|
+
* Защита такого типа держит белый список проверенных роботов и пускает их по адресам, а
|
|
396
|
+
* незнакомого клиента разворачивает. То есть мы писали владельцу сайта неправду про его сайт.
|
|
397
|
+
*
|
|
398
|
+
* Что мы знаем на самом деле: нас не пустили, и что написано в robots.txt. Его отдают и тогда,
|
|
399
|
+
* когда страницы закрыты, поэтому правила прочитать обычно можно. Их и называем, а догадку о
|
|
400
|
+
* поведении защиты не выдаём за измерение.
|
|
401
|
+
*/
|
|
402
|
+
const note = blockedNote({ status: home.status, challenged, blocked, robotsText: robotsRes.ok ? robotsRes.text : null, lang });
|
|
346
403
|
const error = home.error === 'timeout' ? T.tooSlow
|
|
347
|
-
:
|
|
348
|
-
:
|
|
349
|
-
|
|
350
|
-
|
|
404
|
+
: blocked ? note.text
|
|
405
|
+
: T.badAnswer(home.status, url);
|
|
406
|
+
// `botsBlocked` говорит только про написанные правила, а не про поведение защиты: по нему
|
|
407
|
+
// нельзя утверждать, что робота не пустили, можно лишь, что ему не разрешали.
|
|
408
|
+
return { ok: false, blocked, challenged, status: home.status, error, botsBlocked: note.botsBlocked };
|
|
351
409
|
}
|
|
352
410
|
const homePage = analysePage(home.text, home.url);
|
|
353
411
|
const robots = parseRobots(robotsRes.ok ? robotsRes.text : '');
|
|
@@ -372,10 +430,36 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
|
|
|
372
430
|
const blockedTrain = verdicts.filter((v) => v.kind === 'train' && v.verdict === 'blocked');
|
|
373
431
|
const starBlocked = agentVerdict(robots, '__none__') === 'blocked';
|
|
374
432
|
const noai = pages.some((p) => p.noai);
|
|
433
|
+
/*
|
|
434
|
+
* robots.txt может не прочитаться, и тогда мерить эту область нечем.
|
|
435
|
+
*
|
|
436
|
+
* До 15.09.2026 такой случай давал полные 25 из 25 и надпись «Все 14 роботов ИИ допущены на
|
|
437
|
+
* сайт»: файла нет, значит запретов нет, значит отлично. Замерено на cian.ru, где robots.txt
|
|
438
|
+
* отдаёт 403, а сайт при этом разворачивает клиента, представившегося GPTBot. Владелец получал
|
|
439
|
+
* отличную оценку за файл, который мы не открывали.
|
|
440
|
+
*
|
|
441
|
+
* Незнание это не хорошая новость. Область помечается неизмеренной, из знаменателя выпадает,
|
|
442
|
+
* и балл считается по тому, что действительно прочитано.
|
|
443
|
+
*/
|
|
444
|
+
/*
|
|
445
|
+
* «Файла нет» и «нам его не дали» это разные вещи, и путать их нельзя ни в одну сторону.
|
|
446
|
+
*
|
|
447
|
+
* 404 и 410 это определённый ответ: файла на сайте нет, а по стандарту отсутствие robots.txt
|
|
448
|
+
* означает, что не запрещено ничего. Это измерение, и область получает свои баллы честно.
|
|
449
|
+
* Первая версия правки 15.09.2026 считала неизмеренным любой неуспех, и сайт без robots.txt
|
|
450
|
+
* (таких много среди небольших) терял 25 заслуженных баллов: example.com упал с 30 до 7.
|
|
451
|
+
*
|
|
452
|
+
* Неизмеренное это только отказ и сбой: 401, 403, 405, 429, пятисотые, таймаут, недоступность.
|
|
453
|
+
* Тогда мы действительно не знаем, что в файле написано.
|
|
454
|
+
*/
|
|
455
|
+
const robotsMissing = robotsRes.status === 404 || robotsRes.status === 410;
|
|
456
|
+
const robotsRead = robotsRes.ok || robotsMissing;
|
|
375
457
|
let access = 25;
|
|
376
458
|
if (starBlocked) access = 0; else { access -= Math.min(15, blockedSearch.length * 4); access -= Math.min(8, blockedTrain.length * 2); if (noai) access -= 5; }
|
|
377
459
|
access = Math.max(0, access);
|
|
378
460
|
const accessFindings = [];
|
|
461
|
+
if (!robotsRead) accessFindings.push({ id: 'robots-unreadable', level: 'na', text: T.robotsUnreadable(robotsRes.status || 0) });
|
|
462
|
+
else if (robotsMissing && !accessFindings.length) accessFindings.push({ level: 'pass', text: T.robotsMissing });
|
|
379
463
|
if (starBlocked) accessFindings.push({ id: 'robots-all-blocked', level: 'fail', text: T.robotsAllBlocked });
|
|
380
464
|
if (blockedSearch.length) accessFindings.push({ id: 'robots-fetchers-blocked', level: 'fail', text: T.fetchersBlocked(blockedSearch.map(agentLabel).join(', ')) });
|
|
381
465
|
if (blockedTrain.length) accessFindings.push({ id: 'robots-training-blocked', level: 'warn', text: T.trainingBlocked(blockedTrain.map(agentLabel).join(', ')) });
|
|
@@ -472,13 +556,23 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
|
|
|
472
556
|
}
|
|
473
557
|
|
|
474
558
|
const areas = [
|
|
475
|
-
{ id: 'access', label: T.area.access, score: access, max: 25, findings: accessFindings },
|
|
559
|
+
{ id: 'access', label: T.area.access, score: robotsRead ? access : null, max: 25, measured: robotsRead, findings: accessFindings },
|
|
476
560
|
{ id: 'index', label: T.area.index, score: index, max: 15, findings: indexFindings },
|
|
477
561
|
{ id: 'entity', label: T.area.entity, score: entity, max: 20, findings: entityFindings },
|
|
478
562
|
{ id: 'content', label: T.area.content, score: content, max: 25, findings: contentFindings },
|
|
479
563
|
{ id: 'trust', label: T.area.trust, score: trust, max: 15, findings: trustFindings },
|
|
480
564
|
];
|
|
481
|
-
|
|
565
|
+
/*
|
|
566
|
+
* Балл считается по измеренному и переносится на сто.
|
|
567
|
+
*
|
|
568
|
+
* Неизмеренная область выпадает из знаменателя, а не получает ноль: ноль это утверждение, что
|
|
569
|
+
* там плохо, а мы этого не знаем. Правило одно на весь продукт: не измерили, значит не считаем
|
|
570
|
+
* ни в плюс, ни в минус.
|
|
571
|
+
*/
|
|
572
|
+
const measured = areas.filter((a) => a.measured !== false);
|
|
573
|
+
const maxMeasured = measured.reduce((a, x) => a + x.max, 0);
|
|
574
|
+
const rawTotal = measured.reduce((a, x) => a + x.score, 0);
|
|
575
|
+
const total = maxMeasured === 100 ? rawTotal : Math.round((rawTotal / maxMeasured) * 100);
|
|
482
576
|
const grade = total >= 80 ? 'A' : total >= 65 ? 'B' : total >= 45 ? 'C' : total >= 25 ? 'D' : 'E';
|
|
483
577
|
const fixes = areas.flatMap((a) => a.findings.filter((f) => f.level === 'fail').map((f) => ({ id: f.id, area: a.label, text: f.text }))).concat(areas.flatMap((a) => a.findings.filter((f) => f.level === 'warn').map((f) => ({ id: f.id, area: a.label, text: f.text })))).slice(0, 3);
|
|
484
578
|
return {
|