@operstack/audit 0.19.1 → 0.19.3

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.1",
3
+ "version": "0.19.3",
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",
@@ -80,5 +80,21 @@ const CLOSED = 'User-agent: GPTBot\nDisallow: /\n\nUser-agent: ClaudeBot\nDisall
80
80
  ok('в английском нет кириллицы', !/[А-Яа-яЁё]/.test(blockedNote({ status: 403, challenged: true, robotsText: OPEN, lang: 'en' }).text));
81
81
  }
82
82
 
83
+ // ---- несуществующий адрес: человек чаще всего просто опечатался
84
+ //
85
+ // Стояло «Сайт ответил ничем на https://…»: и коряво, и бесполезно. Человек, который ошибся в
86
+ // букве, должен узнать об этом первым делом, а не гадать, что с его сайтом.
87
+ {
88
+ const { checkVisibility } = await import('./visibility.mjs');
89
+ for (const lang of ['ru', 'en']) {
90
+ const v = await checkVisibility('https://takogo-domena-tochno-net-4f7a2b9c.ru/', { budgetMs: 8500, samplePages: 3, lang });
91
+ ok(`${lang}: балла у несуществующего адреса нет`, v.ok === false);
92
+ ok(`${lang}: названа вероятная причина, опечатка`, lang === 'ru' ? /опечатка в адресе/.test(v.error) : /typo in the address/.test(v.error));
93
+ ok(`${lang}: сказано, что делать`, lang === 'ru' ? /проверьте написание/.test(v.error) : /check the spelling/.test(v.error));
94
+ ok(`${lang}: нет старого «ответил ничем»`, lang === 'ru' ? !/ответил ничем/.test(v.error) : !/answered nothing/.test(v.error));
95
+ ok(`${lang}: чужого языка нет`, lang === 'ru' ? !/[A-Za-z]{5}/.test(v.error.replace(/https?:\/\/[^\s]+/g, '')) : !/[А-Яа-яЁё]/.test(v.error));
96
+ }
97
+ }
98
+
83
99
  if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
84
100
  console.log('\nпро чужой сайт говорим только измеренное');
@@ -27,8 +27,9 @@ 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, status = null }) => 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')) {
33
34
  if (status) { res.writeHead(status, { 'content-type': 'text/html' }); res.end('<html>нет</html>'); return; }
34
35
  if (robots === null) { res.writeHead(403, { 'content-type': 'text/html' }); res.end('<html>denied</html>'); return; }
@@ -128,5 +129,43 @@ if (!dnsOk) {
128
129
  ok(`robots.txt ${code}: код назван в тексте`, new RegExp(`код ${code}`).test(a4.findings[0].text));
129
130
  }
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
+
131
170
  if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
132
171
  console.log('\nнепрочитанное не считается ни в плюс, ни в минус, а отсутствующее это ответ');
@@ -48,7 +48,9 @@ export const MESSAGES = {
48
48
  en: {
49
49
  badInput: 'Enter a public site address, for example example.com',
50
50
  tooSlow: 'The site took too long to answer',
51
- badAnswer: (status, url) => `The site answered ${status || 'nothing'} for ${url}`,
51
+ badAnswer: (status, url) => `The site answered ${status} for ${url}`,
52
+ // Чаще всего это опечатка в адресе, и сказать об этом надо первым делом, а не после разбора.
53
+ unreachable: (url) => `Nothing answered at ${url}. Most often that is a typo in the address: check the spelling, and if the site really is there, it may be down right now.`,
52
54
  botWall: (status) => `The site refuses unknown clients (HTTP ${status}), so this check could not read a single page.`,
53
55
  botChallenge: (status) => `The site sits behind a browser challenge (Cloudflare, HTTP ${status}), so this check could not read a single page.`,
54
56
  // Что мы знаем про роботов ИИ, когда нас самих развернули: ровно то, что написано в правилах
@@ -58,6 +60,7 @@ export const MESSAGES = {
58
60
  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
61
  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
62
  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' },
63
+ 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.`,
61
64
  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
65
  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.`,
63
66
  robotsAllBlocked: 'robots.txt disallows the whole site for every crawler. Nothing can read it.',
@@ -97,7 +100,8 @@ export const MESSAGES = {
97
100
  ru: {
98
101
  badInput: 'Введите адрес публичного сайта, например example.ru',
99
102
  tooSlow: 'Сайт слишком долго не отвечал',
100
- badAnswer: (status, url) => `Сайт ответил ${status ? `кодом ${status}` : 'ничем'} на ${url}`,
103
+ badAnswer: (status, url) => `Сайт ответил кодом ${status} на ${url}`,
104
+ unreachable: (url) => `По адресу ${url} никто не ответил. Чаще всего это опечатка в адресе: проверьте написание. Если сайт там действительно есть, значит он сейчас недоступен.`,
101
105
  botWall: (status) => `Сайт отказывает незнакомым клиентам (код ${status}), поэтому прочитать хотя бы одну страницу проверка не смогла.`,
102
106
  botChallenge: (status) => `Сайт закрыт проверкой браузера (Cloudflare, код ${status}), поэтому прочитать хотя бы одну страницу проверка не смогла.`,
103
107
  rulesAllowBots: (list) => ` Правила самого сайта роботов ИИ при этом пускают (${list}), а защита такого типа проверенных роботов нередко пропускает. Пустят ли их на деле, решает защита, а не эта проверка, поэтому в плюс или в минус сайту это не зачтено.`,
@@ -105,6 +109,7 @@ export const MESSAGES = {
105
109
  rulesUnknown: ' Файл robots.txt прочитать тоже не вышло, поэтому про роботов ИИ сказать нечего, и в плюс или в минус сайту это не зачтено.',
106
110
  howToCheckBots: ' Чтобы знать точно, посмотрите в журнале сервера, приходят ли GPTBot, ClaudeBot и PerplexityBot, либо спросите у тех, кто настраивал защиту, кто у неё в белом списке.',
107
111
  area: { access: 'Могут ли роботы ИИ прочитать сайт', index: 'Есть ли карта для агентов (llms.txt)', entity: 'Понятно ли, кто вы (разметка)', content: 'Есть ли что процитировать', trust: 'Можно ли датировать и доверять' },
112
+ llmsUnreadable: (status) => `Сайт не дал этой проверке прочитать llms.txt${status ? ` (код ${status})` : ''}, поэтому есть ли у сайта карта для агентов, измерить не вышло. В плюс или в минус это не зачтено: файл, который нам не отдали, это не то же самое, что файла нет.`,
108
113
  robotsMissing: 'Файла robots.txt на сайте нет. По стандарту это значит, что не запрещено ничего, то есть читать сайт может любой робот ИИ. Заводить файл необязательно: он нужен, только когда есть что закрывать.',
109
114
  robotsUnreadable: (status) => `Сайт не дал этой проверке прочитать robots.txt${status ? ` (код ${status})` : ''}, поэтому допущены роботы ИИ или нет, измерить не вышло. В плюс или в минус сайту это не зачтено: непрочитанный файл это не хорошая новость. Владелец может открыть его в браузере сам или спросить у тех, кто настраивал защиту сайта.`,
110
115
  robotsAllBlocked: 'robots.txt закрывает весь сайт для всех роботов. Его никто не может прочитать.',
@@ -401,8 +406,9 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
401
406
  */
402
407
  const note = blockedNote({ status: home.status, challenged, blocked, robotsText: robotsRes.ok ? robotsRes.text : null, lang });
403
408
  const error = home.error === 'timeout' ? T.tooSlow
404
- : blocked ? note.text
405
- : T.badAnswer(home.status, url);
409
+ : home.error === 'unreachable' || !home.status ? T.unreachable(url)
410
+ : blocked ? note.text
411
+ : T.badAnswer(home.status, url);
406
412
  // `botsBlocked` говорит только про написанные правила, а не про поведение защиты: по нему
407
413
  // нельзя утверждать, что робота не пустили, можно лишь, что ему не разрешали.
408
414
  return { ok: false, blocked, challenged, status: home.status, error, botsBlocked: note.botsBlocked };
@@ -467,8 +473,18 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
467
473
  if (!accessFindings.length) accessFindings.push({ level: 'pass', text: T.allAllowed(AI_AGENTS.length, robots.signal) });
468
474
 
469
475
  // Area 2: agent index (15)
476
+ /*
477
+ * Та же граница, что у robots.txt: «файла нет» и «нам его не дали» это разные вещи.
478
+ *
479
+ * До 15.09.2026 любой неуспех давал вывод «у вас нет llms.txt» и ноль баллов. На сайте за
480
+ * защитой это выдуманная находка: файл может быть, мы его просто не получили. 404 и 410
481
+ * означают, что файла действительно нет, и вот это честная находка с нулём.
482
+ */
483
+ const llmsMissing = llmsRes.status === 404 || llmsRes.status === 410;
484
+ const llmsRead = llmsRes.ok || llmsMissing;
470
485
  let index = 0; const indexFindings = [];
471
- if (!llmsRes.ok) indexFindings.push({ id: 'llms-missing', level: 'fail', text: T.llmsMissing });
486
+ if (!llmsRead) indexFindings.push({ id: 'llms-unreadable', level: 'na', text: T.llmsUnreadable(llmsRes.status || 0) });
487
+ else if (!llmsRes.ok) indexFindings.push({ id: 'llms-missing', level: 'fail', text: T.llmsMissing });
472
488
  else if (!llmsIsText) { index = 3; indexFindings.push({ id: 'llms-not-text', level: 'fail', text: T.llmsNotText }); }
473
489
  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(', ')) }); }
474
490
  else { index = llmsLinks.length >= 5 ? 15 : 10; indexFindings.push({ level: 'pass', text: T.llmsOk(llmsLinks.length) }); }
@@ -557,7 +573,7 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
557
573
 
558
574
  const areas = [
559
575
  { id: 'access', label: T.area.access, score: robotsRead ? access : null, max: 25, measured: robotsRead, findings: accessFindings },
560
- { id: 'index', label: T.area.index, score: index, max: 15, findings: indexFindings },
576
+ { id: 'index', label: T.area.index, score: llmsRead ? index : null, max: 15, measured: llmsRead, findings: indexFindings },
561
577
  { id: 'entity', label: T.area.entity, score: entity, max: 20, findings: entityFindings },
562
578
  { id: 'content', label: T.area.content, score: content, max: 25, findings: contentFindings },
563
579
  { id: 'trust', label: T.area.trust, score: trust, max: 15, findings: trustFindings },