@operstack/audit 0.19.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.19.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",
@@ -27,9 +27,10 @@ const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${
27
27
  const PAGE = `<!doctype html><html lang="ru"><head><title>Пример</title><meta name="description" content="Описание"></head>
28
28
  <body><main><h1>Пример сайта</h1>${'<p>Обычный абзац текста про товар и цену, без цифр, чтобы было что цитировать. </p>'.repeat(40)}</main></body></html>`;
29
29
 
30
- const serve = ({ robots }) => new Promise((resolve) => {
30
+ const serve = ({ robots, status = null }) => new Promise((resolve) => {
31
31
  const s = createServer((req, res) => {
32
32
  if (req.url.startsWith('/robots.txt')) {
33
+ if (status) { res.writeHead(status, { 'content-type': 'text/html' }); res.end('<html>нет</html>'); return; }
33
34
  if (robots === null) { res.writeHead(403, { 'content-type': 'text/html' }); res.end('<html>denied</html>'); return; }
34
35
  res.writeHead(200, { 'content-type': 'text/plain' }); res.end(robots); return;
35
36
  }
@@ -99,5 +100,33 @@ if (!dnsOk) {
99
100
  ok('подпись в отчёте прежняя', /дают в сумме ровно/.test((toHtml({ ...fixture, meta: { ...fixture.meta, lang: 'ru' }, overall: { ...fixture.overall, score: v2.score, areas: v2.areas } }, null).match(/overall-note">([^<]*)</) || [])[1] || ''));
100
101
  }
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
+
102
131
  if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
103
- console.log('\nнепрочитанное не считается ни в плюс, ни в минус');
132
+ console.log('\nнепрочитанное не считается ни в плюс, ни в минус, а отсутствующее это ответ');
@@ -58,6 +58,7 @@ export const MESSAGES = {
58
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
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.',
60
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.',
61
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.`,
62
63
  robotsAllBlocked: 'robots.txt disallows the whole site for every crawler. Nothing can read it.',
63
64
  fetchersBlocked: (list) => `Blocked answer-engine fetchers: ${list}. These are the bots that cite pages live.`,
@@ -104,6 +105,7 @@ export const MESSAGES = {
104
105
  rulesUnknown: ' Файл robots.txt прочитать тоже не вышло, поэтому про роботов ИИ сказать нечего, и в плюс или в минус сайту это не зачтено.',
105
106
  howToCheckBots: ' Чтобы знать точно, посмотрите в журнале сервера, приходят ли GPTBot, ClaudeBot и PerplexityBot, либо спросите у тех, кто настраивал защиту, кто у неё в белом списке.',
106
107
  area: { access: 'Могут ли роботы ИИ прочитать сайт', index: 'Есть ли карта для агентов (llms.txt)', entity: 'Понятно ли, кто вы (разметка)', content: 'Есть ли что процитировать', trust: 'Можно ли датировать и доверять' },
108
+ robotsMissing: 'Файла robots.txt на сайте нет. По стандарту это значит, что не запрещено ничего, то есть читать сайт может любой робот ИИ. Заводить файл необязательно: он нужен, только когда есть что закрывать.',
107
109
  robotsUnreadable: (status) => `Сайт не дал этой проверке прочитать robots.txt${status ? ` (код ${status})` : ''}, поэтому допущены роботы ИИ или нет, измерить не вышло. В плюс или в минус сайту это не зачтено: непрочитанный файл это не хорошая новость. Владелец может открыть его в браузере сам или спросить у тех, кто настраивал защиту сайта.`,
108
110
  robotsAllBlocked: 'robots.txt закрывает весь сайт для всех роботов. Его никто не может прочитать.',
109
111
  fetchersBlocked: (list) => `Закрыты поисковые роботы ответных систем: ${list}. Именно они достают страницу, чтобы процитировать её в ответе.`,
@@ -439,12 +441,25 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
439
441
  * Незнание это не хорошая новость. Область помечается неизмеренной, из знаменателя выпадает,
440
442
  * и балл считается по тому, что действительно прочитано.
441
443
  */
442
- const robotsRead = robotsRes.ok;
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;
443
457
  let access = 25;
444
458
  if (starBlocked) access = 0; else { access -= Math.min(15, blockedSearch.length * 4); access -= Math.min(8, blockedTrain.length * 2); if (noai) access -= 5; }
445
459
  access = Math.max(0, access);
446
460
  const accessFindings = [];
447
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 });
448
463
  if (starBlocked) accessFindings.push({ id: 'robots-all-blocked', level: 'fail', text: T.robotsAllBlocked });
449
464
  if (blockedSearch.length) accessFindings.push({ id: 'robots-fetchers-blocked', level: 'fail', text: T.fetchersBlocked(blockedSearch.map(agentLabel).join(', ')) });
450
465
  if (blockedTrain.length) accessFindings.push({ id: 'robots-training-blocked', level: 'warn', text: T.trainingBlocked(blockedTrain.map(agentLabel).join(', ')) });