@operstack/audit 0.19.1 → 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 +1 -1
- package/src/test-unmeasured.mjs +40 -1
- package/src/visibility.mjs +14 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.19.
|
|
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",
|
package/src/test-unmeasured.mjs
CHANGED
|
@@ -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непрочитанное не считается ни в плюс, ни в минус, а отсутствующее это ответ');
|
package/src/visibility.mjs
CHANGED
|
@@ -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
|
+
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
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.',
|
|
62
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.`,
|
|
63
64
|
robotsAllBlocked: 'robots.txt disallows the whole site for every crawler. Nothing can read it.',
|
|
@@ -105,6 +106,7 @@ export const MESSAGES = {
|
|
|
105
106
|
rulesUnknown: ' Файл robots.txt прочитать тоже не вышло, поэтому про роботов ИИ сказать нечего, и в плюс или в минус сайту это не зачтено.',
|
|
106
107
|
howToCheckBots: ' Чтобы знать точно, посмотрите в журнале сервера, приходят ли GPTBot, ClaudeBot и PerplexityBot, либо спросите у тех, кто настраивал защиту, кто у неё в белом списке.',
|
|
107
108
|
area: { access: 'Могут ли роботы ИИ прочитать сайт', index: 'Есть ли карта для агентов (llms.txt)', entity: 'Понятно ли, кто вы (разметка)', content: 'Есть ли что процитировать', trust: 'Можно ли датировать и доверять' },
|
|
109
|
+
llmsUnreadable: (status) => `Сайт не дал этой проверке прочитать llms.txt${status ? ` (код ${status})` : ''}, поэтому есть ли у сайта карта для агентов, измерить не вышло. В плюс или в минус это не зачтено: файл, который нам не отдали, это не то же самое, что файла нет.`,
|
|
108
110
|
robotsMissing: 'Файла robots.txt на сайте нет. По стандарту это значит, что не запрещено ничего, то есть читать сайт может любой робот ИИ. Заводить файл необязательно: он нужен, только когда есть что закрывать.',
|
|
109
111
|
robotsUnreadable: (status) => `Сайт не дал этой проверке прочитать robots.txt${status ? ` (код ${status})` : ''}, поэтому допущены роботы ИИ или нет, измерить не вышло. В плюс или в минус сайту это не зачтено: непрочитанный файл это не хорошая новость. Владелец может открыть его в браузере сам или спросить у тех, кто настраивал защиту сайта.`,
|
|
110
112
|
robotsAllBlocked: 'robots.txt закрывает весь сайт для всех роботов. Его никто не может прочитать.',
|
|
@@ -467,8 +469,18 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
|
|
|
467
469
|
if (!accessFindings.length) accessFindings.push({ level: 'pass', text: T.allAllowed(AI_AGENTS.length, robots.signal) });
|
|
468
470
|
|
|
469
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;
|
|
470
481
|
let index = 0; const indexFindings = [];
|
|
471
|
-
if (!
|
|
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 });
|
|
472
484
|
else if (!llmsIsText) { index = 3; indexFindings.push({ id: 'llms-not-text', level: 'fail', text: T.llmsNotText }); }
|
|
473
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(', ')) }); }
|
|
474
486
|
else { index = llmsLinks.length >= 5 ? 15 : 10; indexFindings.push({ level: 'pass', text: T.llmsOk(llmsLinks.length) }); }
|
|
@@ -557,7 +569,7 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
|
|
|
557
569
|
|
|
558
570
|
const areas = [
|
|
559
571
|
{ 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 },
|
|
572
|
+
{ id: 'index', label: T.area.index, score: llmsRead ? index : null, max: 15, measured: llmsRead, findings: indexFindings },
|
|
561
573
|
{ id: 'entity', label: T.area.entity, score: entity, max: 20, findings: entityFindings },
|
|
562
574
|
{ id: 'content', label: T.area.content, score: content, max: 25, findings: contentFindings },
|
|
563
575
|
{ id: 'trust', label: T.area.trust, score: trust, max: 15, findings: trustFindings },
|