@operstack/audit 0.21.1 → 0.21.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 +2 -2
- package/src/render.mjs +1 -1
- package/src/test-i18n.mjs +8 -0
- package/src/test-sample.mjs +91 -0
- package/src/visibility.mjs +50 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.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",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"LICENSE"
|
|
30
30
|
],
|
|
31
31
|
"scripts": {
|
|
32
|
-
"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-demand.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
|
+
"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-sample.mjs && node src/test-demand.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",
|
|
33
33
|
"sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
|
|
34
34
|
"verify:live": "node src/verify-live.mjs"
|
|
35
35
|
},
|
package/src/render.mjs
CHANGED
|
@@ -77,7 +77,7 @@ const LABELS = {
|
|
|
77
77
|
const read = o.basis ? `Прочитано ${p} ${pl} сайта${o.basis.sitemapUnchecked ? ', а карта сайта не ответила вовремя, и это не зачтено ни в плюс, ни в минус' : o.basis.sitemapRead ? ' и его карта' : ''}. ` : '';
|
|
78
78
|
const where = o.source === 'visibility:reused'
|
|
79
79
|
? `Это ровно то число, которое вы уже видели в бесплатной проверке${o.measuredAt ? ` ${String(o.measuredAt).slice(0, 10)}` : ''}. Здесь оно не меряется заново, поэтому страница, письмо и этот отчёт всегда несут одно число. `
|
|
80
|
-
: 'Измерено здесь тем же кодом и с теми же настройками, что у бесплатной проверки на oper-stack.
|
|
80
|
+
: 'Измерено здесь тем же кодом и с теми же настройками, что у бесплатной проверки на oper-stack.ru/ai-visibility. ';
|
|
81
81
|
const areas = Array.isArray(o.areas) ? o.areas : [];
|
|
82
82
|
const measured = areas.filter((a) => a.measured !== false && a.score !== null);
|
|
83
83
|
const sumUp = areas.length && measured.length < areas.length
|
package/src/test-i18n.mjs
CHANGED
|
@@ -56,5 +56,13 @@ is('неизмеренное переводится', localiseBasisNote('not mea
|
|
|
56
56
|
|
|
57
57
|
is('английский язык ничего не меняет', localiseChecks(all, 'en'), all);
|
|
58
58
|
|
|
59
|
+
// Русские тексты не отправляют человека на английский сайт: подпись под баллом вела на
|
|
60
|
+
// oper-stack.com, и русский читатель попадал на страницу и цены не на своём языке.
|
|
61
|
+
{
|
|
62
|
+
const ru = readFileSync(new URL('./render.mjs', import.meta.url), 'utf8').split('\n')
|
|
63
|
+
.filter((l) => /[А-Яа-яЁё]/.test(l) && /oper-stack\.com/.test(l) && !l.trim().startsWith('*') && !l.trim().startsWith('//'));
|
|
64
|
+
is('в русских строках нет ссылок на английский сайт', ru, []);
|
|
65
|
+
}
|
|
66
|
+
|
|
59
67
|
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
60
68
|
console.log(`\nперевод проверен на ${all.length} проверках, всё чисто`);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Прочитать меньше не должно выглядеть как «стало лучше».
|
|
4
|
+
*
|
|
5
|
+
* 15.09.2026 один и тот же сайт в шести прогонах подряд дал 46, 54, 59 и 69 баллов. Выше он
|
|
6
|
+
* выходил там, где из выборки успела ответить одна страница вместо двух: области «есть ли что
|
|
7
|
+
* процитировать» и «дата и доверие» считались по одной главной, а она у большинства сайтов
|
|
8
|
+
* лучше внутренних страниц. То есть медленная сеть выглядела как хороший сайт, и покупатель,
|
|
9
|
+
* запустивший проверку дважды, видел два разных числа.
|
|
10
|
+
*
|
|
11
|
+
* Теперь: страниц хватало, а ответили не все, значит эти две области не измерены. Балл считается
|
|
12
|
+
* по остальным и переносится на сто, как и при нечитаемом robots.txt. Здесь это держится на
|
|
13
|
+
* живом сервере: одна страница выборки отвечает сразу, вторая висит дольше отпущенного времени.
|
|
14
|
+
*/
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { lookup } from 'node:dns/promises';
|
|
17
|
+
import { checkVisibility } from './visibility.mjs';
|
|
18
|
+
|
|
19
|
+
let bad = 0;
|
|
20
|
+
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
21
|
+
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
22
|
+
|
|
23
|
+
const page = (title, extra = '') => `<!doctype html><html lang="ru"><head><title>${title}</title>
|
|
24
|
+
<meta name="description" content="Описание страницы для проверки, достаточно длинное чтобы не считаться пустым.">
|
|
25
|
+
<link rel="canonical" href="http://example.com/"></head><body><main><h1>${title}</h1>
|
|
26
|
+
${'<p>Обычный абзац текста про товар, услугу и цену без источника.</p>'.repeat(12)}${extra}</main></body></html>`;
|
|
27
|
+
|
|
28
|
+
/** Сервер: главная и две страницы в карте сайта, вторая из них отвечает медленнее отпущенного. */
|
|
29
|
+
const serve = ({ slowPages = 0, slowMs = 9000 } = {}) => new Promise((resolve) => {
|
|
30
|
+
const s = createServer((req, res) => {
|
|
31
|
+
const send = (code, type, body) => { res.writeHead(code, { 'content-type': type }); res.end(body); };
|
|
32
|
+
if (req.url.startsWith('/robots.txt')) return send(200, 'text/plain', `User-agent: *\nAllow: /\nSitemap: http://lvh.me:${s.address().port}/sitemap.xml\n`);
|
|
33
|
+
if (req.url.startsWith('/llms.txt')) return send(404, 'text/plain', 'no');
|
|
34
|
+
if (req.url.startsWith('/sitemap.xml')) {
|
|
35
|
+
const base = `http://lvh.me:${s.address().port}`;
|
|
36
|
+
// Восемь внутренних страниц: движок сортирует их по длине адреса и берёт каждую четвёртую,
|
|
37
|
+
// поэтому выборка из двух складывается только на сайте такого размера.
|
|
38
|
+
const urls = ['/', ...Array.from({ length: 8 }, (_, i) => `/stranica-nomer-${i}${'-x'.repeat(8 - i)}/`)];
|
|
39
|
+
return send(200, 'application/xml', `<?xml version="1.0"?><urlset>${urls.map((u) => `<url><loc>${base}${u}</loc></url>`).join('')}</urlset>`);
|
|
40
|
+
}
|
|
41
|
+
if (req.url === '/') return send(200, 'text/html; charset=utf-8', page('Главная'));
|
|
42
|
+
if (req.url.startsWith('/stranica-')) {
|
|
43
|
+
if (slowPages > 0) { setTimeout(() => send(200, 'text/html; charset=utf-8', page('Медленная')), slowMs); return; }
|
|
44
|
+
return send(200, 'text/html; charset=utf-8', page('Внутренняя'));
|
|
45
|
+
}
|
|
46
|
+
return send(404, 'text/plain', 'no');
|
|
47
|
+
});
|
|
48
|
+
s.listen(0, '127.0.0.1', () => resolve({ s, url: `http://lvh.me:${s.address().port}/` }));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
let dnsOk = true;
|
|
52
|
+
try { const a = await lookup('lvh.me'); dnsOk = a.address === '127.0.0.1'; } catch { dnsOk = false; }
|
|
53
|
+
if (!dnsOk) { console.log('ok пропуск: lvh.me не указывает на 127.0.0.1 (нет сети)'); process.exit(0); }
|
|
54
|
+
|
|
55
|
+
const area = (v, id) => (v.areas || []).find((a) => a.id === id);
|
|
56
|
+
|
|
57
|
+
// ---- все страницы выборки ответили: области считаются
|
|
58
|
+
{
|
|
59
|
+
const { s, url } = await serve();
|
|
60
|
+
const v = await checkVisibility(url, { lang: 'ru', samplePages: 2, budgetMs: 8000 });
|
|
61
|
+
ok('сайт прочитан', v.ok);
|
|
62
|
+
ok('обе области по страницам измерены', area(v, 'content').measured !== false && area(v, 'trust').measured !== false);
|
|
63
|
+
ok('балл посчитан из всех пяти областей', typeof v.score === 'number' && v.score > 0);
|
|
64
|
+
s.close();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- страницы выборки не отвечают даже со второй попытки: балла нет вовсе
|
|
68
|
+
{
|
|
69
|
+
const { s, url } = await serve({ slowPages: 1 });
|
|
70
|
+
const v = await checkVisibility(url, { lang: 'ru', samplePages: 2, budgetMs: 6000 });
|
|
71
|
+
is('балл не выдан', v.ok, false);
|
|
72
|
+
ok('сказано, сколько страниц ответило', /Из \d+ страниц ответили \d+/.test(v.error));
|
|
73
|
+
ok('сказано, что число не показываем и почему', /защитить нельзя/.test(v.error));
|
|
74
|
+
ok('предложено повторить', /через минуту/.test(v.error));
|
|
75
|
+
ok('это не выдаётся за блокировку', v.blocked === false && v.challenged === false);
|
|
76
|
+
ok('в сообщении нет null и undefined', !/null|undefined|NaN/.test(v.error));
|
|
77
|
+
const en = await checkVisibility(url, { lang: 'en', samplePages: 2, budgetMs: 6000 });
|
|
78
|
+
ok('по-английски то же самое', en.ok === false && /not enough of the site to score/.test(en.error) && !/[А-Яа-яЁё]/.test(en.error));
|
|
79
|
+
s.close();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- все страницы отвечают быстро: областям есть на чём считаться
|
|
83
|
+
{
|
|
84
|
+
const { s, url } = await serve();
|
|
85
|
+
const v = await checkVisibility(url, { lang: 'ru', samplePages: 2, budgetMs: 8000 });
|
|
86
|
+
ok('быстрый сайт измерен целиком', area(v, 'content').measured !== false && area(v, 'trust').measured !== false);
|
|
87
|
+
s.close();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
91
|
+
console.log('\nвыборка: прочитать меньше больше не выглядит как «стало лучше»');
|
package/src/visibility.mjs
CHANGED
|
@@ -62,6 +62,7 @@ export const MESSAGES = {
|
|
|
62
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
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.`,
|
|
64
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.',
|
|
65
|
+
sampleShort: (read, wanted) => `Only ${read} of ${wanted} pages answered in time, twice in a row, so there was not enough of the site to score. We do not show a number here: a score that goes up because we read less is a score nobody can defend. The site may simply be slow right now; run the check again in a minute.`,
|
|
65
66
|
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.`,
|
|
66
67
|
robotsAllBlocked: 'robots.txt disallows the whole site for every crawler. Nothing can read it.',
|
|
67
68
|
fetchersBlocked: (list) => `Blocked answer-engine fetchers: ${list}. These are the bots that cite pages live.`,
|
|
@@ -111,6 +112,7 @@ export const MESSAGES = {
|
|
|
111
112
|
area: { access: 'Могут ли роботы ИИ прочитать сайт', index: 'Есть ли карта для агентов (llms.txt)', entity: 'Понятно ли, кто вы (разметка)', content: 'Есть ли что процитировать', trust: 'Можно ли датировать и доверять' },
|
|
112
113
|
llmsUnreadable: (status) => `Сайт не дал этой проверке прочитать llms.txt${status ? ` (код ${status})` : ''}, поэтому есть ли у сайта карта для агентов, измерить не вышло. В плюс или в минус это не зачтено: файл, который нам не отдали, это не то же самое, что файла нет.`,
|
|
113
114
|
robotsMissing: 'Файла robots.txt на сайте нет. По стандарту это значит, что не запрещено ничего, то есть читать сайт может любой робот ИИ. Заводить файл необязательно: он нужен, только когда есть что закрывать.',
|
|
115
|
+
sampleShort: (read, wanted) => `Из ${wanted} страниц ответили ${read}, и со второй попытки тоже, поэтому считать балл не по чему. Числа здесь не будет: балл, который растёт оттого, что мы прочитали меньше, защитить нельзя. Возможно, сайт сейчас просто медленный: запустите проверку через минуту.`,
|
|
114
116
|
robotsUnreadable: (status) => `Сайт не дал этой проверке прочитать robots.txt${status ? ` (код ${status})` : ''}, поэтому допущены роботы ИИ или нет, измерить не вышло. В плюс или в минус сайту это не зачтено: непрочитанный файл это не хорошая новость. Владелец может открыть его в браузере сам или спросить у тех, кто настраивал защиту сайта.`,
|
|
115
117
|
robotsAllBlocked: 'robots.txt закрывает весь сайт для всех роботов. Его никто не может прочитать.',
|
|
116
118
|
fetchersBlocked: (list) => `Закрыты поисковые роботы ответных систем: ${list}. Именно они достают страницу, чтобы процитировать её в ответе.`,
|
|
@@ -376,7 +378,8 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
|
|
|
376
378
|
const agentLabel = (a) => (lang === 'ru' ? a.labelRu : a.label);
|
|
377
379
|
const started = Date.now();
|
|
378
380
|
const deadline = started + budgetMs;
|
|
379
|
-
|
|
381
|
+
let grace = 0;
|
|
382
|
+
const left = () => deadline + grace - Date.now();
|
|
380
383
|
const url = normaliseInput(input);
|
|
381
384
|
if (!url) return { ok: false, error: T.badInput };
|
|
382
385
|
const origin = new URL(url).origin; const host = new URL(url).host;
|
|
@@ -417,11 +420,39 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
|
|
|
417
420
|
const robots = parseRobots(robotsRes.ok ? robotsRes.text : '');
|
|
418
421
|
const sitemap = left() > 1500 ? await readSitemap(origin, robotsRes.ok ? robotsRes.text : '', () => Math.min(3000, left() - 300)) : { found: false, url: '', count: 0, lastmod: false, pages: [], skipped: true };
|
|
419
422
|
let sampled = [];
|
|
423
|
+
/*
|
|
424
|
+
* Сколько страниц выборки мы собирались прочитать. Нужно, чтобы отличить «сайт маленький» от
|
|
425
|
+
* «страницы не успели ответить»: в первом случае меряем по тому, что есть, во втором мерить
|
|
426
|
+
* нечем. Найдено 15.09.2026: habr.com в шести прогонах подряд дал 46, 54, 59 и 69, и выше
|
|
427
|
+
* он выходил там, где успела прочитаться одна страница вместо двух. То есть прочитать меньше
|
|
428
|
+
* выглядело как «стало лучше», а это ровно то, чего продукт обещает не делать.
|
|
429
|
+
*/
|
|
430
|
+
let wantedPages = 0;
|
|
420
431
|
if (left() > 2000 && sitemap.pages.length) {
|
|
421
432
|
const norm = (u) => u.replace(/\/$/, '').toLowerCase();
|
|
422
433
|
const picks = sitemap.pages.filter((p) => norm(p) !== norm(home.url) && p.startsWith(origin)).sort((a, b) => b.length - a.length).slice(0, Math.max(12, samplePages * 4)).filter((_, i) => i % 4 === 0).slice(0, samplePages);
|
|
423
|
-
const
|
|
434
|
+
const read = async (list) => Promise.all(list.map((p) => get(p, { timeout: Math.min(4000, left() - 300) })));
|
|
435
|
+
let rs = await read(picks);
|
|
436
|
+
// Одна повторная попытка по тем, что не ответили: страницы маленькие, а разница в балле
|
|
437
|
+
// между «прочитали две» и «прочитали одну» доходила до двадцати пунктов.
|
|
438
|
+
const failed = picks.filter((p, i) => !(rs[i].ok && /html/i.test(rs[i].type)));
|
|
439
|
+
if (failed.length) {
|
|
440
|
+
// Повтору даётся своё время сверх общего бюджета, один раз и не больше трёх секунд.
|
|
441
|
+
// Без этого на медленном сайте повтор упирался в тот же истёкший бюджет и проверка
|
|
442
|
+
// отказывалась считать балл там, где сайт просто отвечает за пять секунд, а не за две.
|
|
443
|
+
grace = 5000;
|
|
444
|
+
let again = await Promise.all(failed.map((p) => get(p, { timeout: Math.min(3500, left() - 200) })));
|
|
445
|
+
rs = rs.concat(again);
|
|
446
|
+
// Вторая и последняя попытка по тем, что снова молчат: три обращения к странице это
|
|
447
|
+
// предел, дальше мы уже не меряем сайт, а ждём его.
|
|
448
|
+
const stillFailed = failed.filter((p, i) => !(again[i].ok && /html/i.test(again[i].type)));
|
|
449
|
+
if (stillFailed.length && left() > 1200) rs = rs.concat(await Promise.all(stillFailed.map((p) => get(p, { timeout: Math.min(2500, left() - 200) }))));
|
|
450
|
+
}
|
|
424
451
|
sampled = rs.filter((r) => r.ok && /html/i.test(r.type)).map((r) => analysePage(r.text, r.url));
|
|
452
|
+
// Дубли по адресу: повтор мог вернуть ту же страницу, что и первая попытка.
|
|
453
|
+
const seenUrl = new Set();
|
|
454
|
+
sampled = sampled.filter((p) => (seenUrl.has(p.url) ? false : seenUrl.add(p.url)));
|
|
455
|
+
wantedPages = Math.min(picks.length, samplePages);
|
|
425
456
|
}
|
|
426
457
|
const pages = [homePage, ...sampled];
|
|
427
458
|
|
|
@@ -571,12 +602,27 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
|
|
|
571
602
|
trustFindings.push({ level: 'fail', text: T.sitemapMissing });
|
|
572
603
|
}
|
|
573
604
|
|
|
605
|
+
/*
|
|
606
|
+
* Выборка так и не прочитана даже со второй попытки. Балла не будет.
|
|
607
|
+
*
|
|
608
|
+
* Почему не «посчитаем по тому, что успели»: 15.09.2026 один и тот же сайт в шести прогонах
|
|
609
|
+
* дал 46, 54, 59 и 69, и выше он выходил там, где прочиталась одна страница вместо двух.
|
|
610
|
+
* Главная у большинства сайтов лучше внутренних, поэтому меньше чтения выглядело как лучший
|
|
611
|
+
* сайт. Число, которое растёт от того, что мы прочитали меньше, защитить нельзя, поэтому его
|
|
612
|
+
* не показываем вовсе: тот же путь, что у сайта, который нам не ответил.
|
|
613
|
+
*/
|
|
614
|
+
if (wantedPages > 0 && sampled.length < wantedPages) {
|
|
615
|
+
return { ok: false, blocked: false, challenged: false, status: home.status, error: T.sampleShort(sampled.length + 1, wantedPages + 1), botsBlocked: [] };
|
|
616
|
+
}
|
|
617
|
+
const sampleOk = true;
|
|
618
|
+
const sampleNote = null;
|
|
619
|
+
|
|
574
620
|
const areas = [
|
|
575
621
|
{ id: 'access', label: T.area.access, score: robotsRead ? access : null, max: 25, measured: robotsRead, findings: accessFindings },
|
|
576
622
|
{ id: 'index', label: T.area.index, score: llmsRead ? index : null, max: 15, measured: llmsRead, findings: indexFindings },
|
|
577
623
|
{ id: 'entity', label: T.area.entity, score: entity, max: 20, findings: entityFindings },
|
|
578
|
-
{ id: 'content', label: T.area.content, score: content, max: 25, findings: contentFindings },
|
|
579
|
-
{ id: 'trust', label: T.area.trust, score: trust, max: 15, findings: trustFindings },
|
|
624
|
+
{ id: 'content', label: T.area.content, score: sampleOk ? content : null, max: 25, measured: sampleOk, findings: sampleOk ? contentFindings : [sampleNote] },
|
|
625
|
+
{ id: 'trust', label: T.area.trust, score: sampleOk ? trust : null, max: 15, measured: sampleOk, findings: sampleOk ? trustFindings : [sampleNote] },
|
|
580
626
|
];
|
|
581
627
|
/*
|
|
582
628
|
* Балл считается по измеренному и переносится на сто.
|