@operstack/audit 0.19.0 → 0.19.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.19.0",
3
+ "version": "0.19.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",
@@ -27,9 +27,11 @@ 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, llmsStatus = 404 }) => new Promise((resolve) => {
31
31
  const s = createServer((req, res) => {
32
+ if (req.url.startsWith('/llms.txt')) { res.writeHead(llmsStatus, { 'content-type': 'text/html' }); res.end('<html>нет</html>'); return; }
32
33
  if (req.url.startsWith('/robots.txt')) {
34
+ if (status) { res.writeHead(status, { 'content-type': 'text/html' }); res.end('<html>нет</html>'); return; }
33
35
  if (robots === null) { res.writeHead(403, { 'content-type': 'text/html' }); res.end('<html>denied</html>'); return; }
34
36
  res.writeHead(200, { 'content-type': 'text/plain' }); res.end(robots); return;
35
37
  }
@@ -99,5 +101,71 @@ if (!dnsOk) {
99
101
  ok('подпись в отчёте прежняя', /дают в сумме ровно/.test((toHtml({ ...fixture, meta: { ...fixture.meta, lang: 'ru' }, overall: { ...fixture.overall, score: v2.score, areas: v2.areas } }, null).match(/overall-note">([^<]*)</) || [])[1] || ''));
100
102
  }
101
103
 
104
+ // ---- файла нет это ОТВЕТ, а не незнание
105
+ //
106
+ // Первая версия правки считала неизмеренным любой неуспех, и сайт без robots.txt терял 25
107
+ // заслуженных баллов: по стандарту отсутствие файла означает, что не запрещено ничего.
108
+ // Поймано на живом example.com сразу после выкладки: балл упал с 30 до 7.
109
+ for (const code of [404, 410]) {
110
+ const { s: s3, url: url3 } = await serve({ status: code });
111
+ let v3;
112
+ try { v3 = await checkVisibility(url3, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s3.close(); }
113
+ const a3 = v3.areas.find((a) => a.id === 'access');
114
+ is(`robots.txt ${code}: область измерена`, a3.measured, true);
115
+ is(`robots.txt ${code}: и оценена полностью`, a3.score, 25);
116
+ ok(`robots.txt ${code}: сказано, что файла нет`, /Файла robots\.txt на сайте нет/.test(a3.findings[0].text));
117
+ ok(`robots.txt ${code}: и что это значит «не запрещено ничего»`, /не запрещено ничего/.test(a3.findings[0].text));
118
+ ok(`robots.txt ${code}: не назвали это непрочитанным`, !a3.findings.some((f) => f.id === 'robots-unreadable'));
119
+ is(`robots.txt ${code}: балл это прямая сумма`, v3.score, v3.areas.reduce((t, a) => t + a.score, 0));
120
+ }
121
+
122
+ // ---- а отказ и сбой это по-прежнему незнание
123
+ for (const code of [401, 403, 429, 500]) {
124
+ const { s: s4, url: url4 } = await serve({ status: code });
125
+ let v4;
126
+ try { v4 = await checkVisibility(url4, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s4.close(); }
127
+ const a4 = v4.areas.find((a) => a.id === 'access');
128
+ is(`robots.txt ${code}: область не измерена`, a4.measured, false);
129
+ ok(`robots.txt ${code}: код назван в тексте`, new RegExp(`код ${code}`).test(a4.findings[0].text));
130
+ }
131
+
132
+ // ---- llms.txt: та же граница между «нет файла» и «не дали файл»
133
+ //
134
+ // Сайт за защитой отдавал 403 на llms.txt, и мы писали владельцу «у вас нет карты для агентов»
135
+ // с нулём баллов. Файл мог быть, мы его просто не получили.
136
+ {
137
+ const cases = [[404, 'missing'], [410, 'missing'], [403, 'unknown'], [429, 'unknown'], [500, 'unknown']];
138
+ for (const [code, kind] of cases) {
139
+ const { s: s5, url: url5 } = await serve({ robots: 'User-agent: *\nDisallow: /admin/\n', llmsStatus: code });
140
+ let v5;
141
+ try { v5 = await checkVisibility(url5, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s5.close(); }
142
+ const a5 = v5.areas.find((a) => a.id === 'index');
143
+ if (kind === 'missing') {
144
+ is(`llms.txt ${code}: область измерена`, a5.measured, true);
145
+ is(`llms.txt ${code}: и это честный ноль`, a5.score, 0);
146
+ ok(`llms.txt ${code}: находка называет отсутствие`, a5.findings.some((f) => f.id === 'llms-missing'));
147
+ } else {
148
+ is(`llms.txt ${code}: область не измерена`, a5.measured, false);
149
+ is(`llms.txt ${code}: балла нет, а не ноль`, a5.score, null);
150
+ ok(`llms.txt ${code}: не пишем «у вас нет файла»`, !a5.findings.some((f) => f.id === 'llms-missing'));
151
+ ok(`llms.txt ${code}: сказано, что не отдали`, /не дал этой проверке прочитать llms\.txt/.test(a5.findings[0].text));
152
+ is(`llms.txt ${code}: знаменатель без этой области`, v5.score, Math.round((v5.areas.filter((a) => a.measured !== false).reduce((t, a) => t + a.score, 0) / v5.areas.filter((a) => a.measured !== false).reduce((t, a) => t + a.max, 0)) * 100));
153
+ }
154
+ }
155
+ }
156
+
157
+ // ---- две неизмеренные области сразу: знаменатель считается по оставшимся трём
158
+ {
159
+ const { s: s6, url: url6 } = await serve({ robots: null, llmsStatus: 403 });
160
+ let v6;
161
+ try { v6 = await checkVisibility(url6, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s6.close(); }
162
+ const un = v6.areas.filter((a) => a.measured === false);
163
+ is('неизмеренных областей две', un.length, 2);
164
+ const meas = v6.areas.filter((a) => a.measured !== false);
165
+ is('знаменатель 60', meas.reduce((t, a) => t + a.max, 0), 60);
166
+ is('балл по оставшимся', v6.score, Math.round((meas.reduce((t, a) => t + a.score, 0) / 60) * 100));
167
+ ok('ни одна из них не получила ноль', un.every((a) => a.score === null));
168
+ }
169
+
102
170
  if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
103
- console.log('\nнепрочитанное не считается ни в плюс, ни в минус');
171
+ console.log('\nнепрочитанное не считается ни в плюс, ни в минус, а отсутствующее это ответ');
@@ -58,6 +58,8 @@ 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
+ llmsUnreadable: (status) => `The site did not let this check read llms.txt${status ? ` (HTTP ${status})` : ''}, so whether the site has a map for agents could not be measured. It is not counted for or against the site: a file we were refused is not the same as a file that is not there.`,
62
+ 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
63
  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
64
  robotsAllBlocked: 'robots.txt disallows the whole site for every crawler. Nothing can read it.',
63
65
  fetchersBlocked: (list) => `Blocked answer-engine fetchers: ${list}. These are the bots that cite pages live.`,
@@ -104,6 +106,8 @@ export const MESSAGES = {
104
106
  rulesUnknown: ' Файл robots.txt прочитать тоже не вышло, поэтому про роботов ИИ сказать нечего, и в плюс или в минус сайту это не зачтено.',
105
107
  howToCheckBots: ' Чтобы знать точно, посмотрите в журнале сервера, приходят ли GPTBot, ClaudeBot и PerplexityBot, либо спросите у тех, кто настраивал защиту, кто у неё в белом списке.',
106
108
  area: { access: 'Могут ли роботы ИИ прочитать сайт', index: 'Есть ли карта для агентов (llms.txt)', entity: 'Понятно ли, кто вы (разметка)', content: 'Есть ли что процитировать', trust: 'Можно ли датировать и доверять' },
109
+ llmsUnreadable: (status) => `Сайт не дал этой проверке прочитать llms.txt${status ? ` (код ${status})` : ''}, поэтому есть ли у сайта карта для агентов, измерить не вышло. В плюс или в минус это не зачтено: файл, который нам не отдали, это не то же самое, что файла нет.`,
110
+ robotsMissing: 'Файла robots.txt на сайте нет. По стандарту это значит, что не запрещено ничего, то есть читать сайт может любой робот ИИ. Заводить файл необязательно: он нужен, только когда есть что закрывать.',
107
111
  robotsUnreadable: (status) => `Сайт не дал этой проверке прочитать robots.txt${status ? ` (код ${status})` : ''}, поэтому допущены роботы ИИ или нет, измерить не вышло. В плюс или в минус сайту это не зачтено: непрочитанный файл это не хорошая новость. Владелец может открыть его в браузере сам или спросить у тех, кто настраивал защиту сайта.`,
108
112
  robotsAllBlocked: 'robots.txt закрывает весь сайт для всех роботов. Его никто не может прочитать.',
109
113
  fetchersBlocked: (list) => `Закрыты поисковые роботы ответных систем: ${list}. Именно они достают страницу, чтобы процитировать её в ответе.`,
@@ -439,12 +443,25 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
439
443
  * Незнание это не хорошая новость. Область помечается неизмеренной, из знаменателя выпадает,
440
444
  * и балл считается по тому, что действительно прочитано.
441
445
  */
442
- const robotsRead = robotsRes.ok;
446
+ /*
447
+ * «Файла нет» и «нам его не дали» это разные вещи, и путать их нельзя ни в одну сторону.
448
+ *
449
+ * 404 и 410 это определённый ответ: файла на сайте нет, а по стандарту отсутствие robots.txt
450
+ * означает, что не запрещено ничего. Это измерение, и область получает свои баллы честно.
451
+ * Первая версия правки 15.09.2026 считала неизмеренным любой неуспех, и сайт без robots.txt
452
+ * (таких много среди небольших) терял 25 заслуженных баллов: example.com упал с 30 до 7.
453
+ *
454
+ * Неизмеренное это только отказ и сбой: 401, 403, 405, 429, пятисотые, таймаут, недоступность.
455
+ * Тогда мы действительно не знаем, что в файле написано.
456
+ */
457
+ const robotsMissing = robotsRes.status === 404 || robotsRes.status === 410;
458
+ const robotsRead = robotsRes.ok || robotsMissing;
443
459
  let access = 25;
444
460
  if (starBlocked) access = 0; else { access -= Math.min(15, blockedSearch.length * 4); access -= Math.min(8, blockedTrain.length * 2); if (noai) access -= 5; }
445
461
  access = Math.max(0, access);
446
462
  const accessFindings = [];
447
463
  if (!robotsRead) accessFindings.push({ id: 'robots-unreadable', level: 'na', text: T.robotsUnreadable(robotsRes.status || 0) });
464
+ else if (robotsMissing && !accessFindings.length) accessFindings.push({ level: 'pass', text: T.robotsMissing });
448
465
  if (starBlocked) accessFindings.push({ id: 'robots-all-blocked', level: 'fail', text: T.robotsAllBlocked });
449
466
  if (blockedSearch.length) accessFindings.push({ id: 'robots-fetchers-blocked', level: 'fail', text: T.fetchersBlocked(blockedSearch.map(agentLabel).join(', ')) });
450
467
  if (blockedTrain.length) accessFindings.push({ id: 'robots-training-blocked', level: 'warn', text: T.trainingBlocked(blockedTrain.map(agentLabel).join(', ')) });
@@ -452,8 +469,18 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
452
469
  if (!accessFindings.length) accessFindings.push({ level: 'pass', text: T.allAllowed(AI_AGENTS.length, robots.signal) });
453
470
 
454
471
  // Area 2: agent index (15)
472
+ /*
473
+ * Та же граница, что у robots.txt: «файла нет» и «нам его не дали» это разные вещи.
474
+ *
475
+ * До 15.09.2026 любой неуспех давал вывод «у вас нет llms.txt» и ноль баллов. На сайте за
476
+ * защитой это выдуманная находка: файл может быть, мы его просто не получили. 404 и 410
477
+ * означают, что файла действительно нет, и вот это честная находка с нулём.
478
+ */
479
+ const llmsMissing = llmsRes.status === 404 || llmsRes.status === 410;
480
+ const llmsRead = llmsRes.ok || llmsMissing;
455
481
  let index = 0; const indexFindings = [];
456
- if (!llmsRes.ok) indexFindings.push({ id: 'llms-missing', level: 'fail', text: T.llmsMissing });
482
+ if (!llmsRead) indexFindings.push({ id: 'llms-unreadable', level: 'na', text: T.llmsUnreadable(llmsRes.status || 0) });
483
+ else if (!llmsRes.ok) indexFindings.push({ id: 'llms-missing', level: 'fail', text: T.llmsMissing });
457
484
  else if (!llmsIsText) { index = 3; indexFindings.push({ id: 'llms-not-text', level: 'fail', text: T.llmsNotText }); }
458
485
  else if (llmsLinks.length && llmsForeign.length > llmsLinks.length / 2) { index = 5; indexFindings.push({ id: 'llms-foreign', level: 'fail', text: T.llmsForeign([...new Set(llmsForeign.map((l) => new URL(l).host))].slice(0, 3).join(', ')) }); }
459
486
  else { index = llmsLinks.length >= 5 ? 15 : 10; indexFindings.push({ level: 'pass', text: T.llmsOk(llmsLinks.length) }); }
@@ -542,7 +569,7 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
542
569
 
543
570
  const areas = [
544
571
  { id: 'access', label: T.area.access, score: robotsRead ? access : null, max: 25, measured: robotsRead, findings: accessFindings },
545
- { id: 'index', label: T.area.index, score: index, max: 15, findings: indexFindings },
572
+ { id: 'index', label: T.area.index, score: llmsRead ? index : null, max: 15, measured: llmsRead, findings: indexFindings },
546
573
  { id: 'entity', label: T.area.entity, score: entity, max: 20, findings: entityFindings },
547
574
  { id: 'content', label: T.area.content, score: content, max: 25, findings: contentFindings },
548
575
  { id: 'trust', label: T.area.trust, score: trust, max: 15, findings: trustFindings },