@operstack/audit 0.21.2 → 0.21.4
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/render.mjs +1 -1
- package/src/test-flaky-files.mjs +81 -0
- package/src/test-i18n.mjs +8 -0
- package/src/visibility.mjs +26 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.4",
|
|
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/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
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Один сбой сети не должен менять балл.
|
|
4
|
+
*
|
|
5
|
+
* 15.09.2026 сайт avtokomissar-app.ru получил 52 на русской площадке и 67 на английской с
|
|
6
|
+
* разницей в две минуты. Движок, его версия и параметры у обеих одинаковые, значит дело было в
|
|
7
|
+
* самом прогоне: служебные файлы читались по одному разу, и один неудачный ответ решал судьбу
|
|
8
|
+
* целой области. robots.txt стоит 25 очков, llms.txt 15. Владелец сайта читает такое расхождение
|
|
9
|
+
* как «вы врёте», и он прав.
|
|
10
|
+
*
|
|
11
|
+
* Здесь сервер отдаёт llms.txt отказом только на первый запрос, а со второго нормально. Балл
|
|
12
|
+
* обязан совпасть с тем, что даёт всегда исправный сервер.
|
|
13
|
+
*
|
|
14
|
+
* Сервер поднимается на loopback, а зовётся по имени lvh.me: оно публично указывает на 127.0.0.1,
|
|
15
|
+
* и так проверка проходит свой же запрет на частные адреса честно, без лазейки в коде продукта.
|
|
16
|
+
*/
|
|
17
|
+
import { createServer } from 'node:http';
|
|
18
|
+
import { lookup } from 'node:dns/promises';
|
|
19
|
+
import { checkVisibility } from './visibility.mjs';
|
|
20
|
+
|
|
21
|
+
let bad = 0;
|
|
22
|
+
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
23
|
+
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
24
|
+
|
|
25
|
+
const PAGE = `<!doctype html><html lang="ru"><head><title>Пример</title><meta name="description" content="Описание"></head>
|
|
26
|
+
<body><main><h1>Пример сайта</h1>${'<p>Обычный абзац текста про товар и услугу, чтобы было что цитировать. </p>'.repeat(40)}</main></body></html>`;
|
|
27
|
+
const LLMS = `# Пример\n\n> Короткое описание сайта.\n\n## Разделы\n\n${Array.from({ length: 6 }, (_, i) => `- [Страница ${i}](http://HOST/p${i}.html): о чём она`).join('\n')}\n`;
|
|
28
|
+
|
|
29
|
+
/** @param failFirst сколько первых запросов к llms.txt отдать отказом */
|
|
30
|
+
const serve = (failFirst) => new Promise((resolve) => {
|
|
31
|
+
let asked = 0;
|
|
32
|
+
const s = createServer((req, res) => {
|
|
33
|
+
if (req.url.startsWith('/llms.txt')) {
|
|
34
|
+
asked += 1;
|
|
35
|
+
if (asked <= failFirst) { res.writeHead(503, { 'content-type': 'text/html' }); res.end('<html>занято</html>'); return; }
|
|
36
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
|
|
37
|
+
res.end(LLMS.replaceAll('HOST', req.headers.host));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (req.url.startsWith('/robots.txt')) { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('User-agent: *\nAllow: /\n'); return; }
|
|
41
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(PAGE);
|
|
42
|
+
});
|
|
43
|
+
s.listen(0, '127.0.0.1', () => resolve({ s, url: `http://lvh.me:${s.address().port}/`, asked: () => asked }));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
let dnsOk = true;
|
|
47
|
+
try { const a = await lookup('lvh.me'); dnsOk = a.address === '127.0.0.1'; } catch { dnsOk = false; }
|
|
48
|
+
|
|
49
|
+
if (!dnsOk) {
|
|
50
|
+
console.log('ПРОПУЩЕНО: lvh.me не указывает на 127.0.0.1 с этой машины');
|
|
51
|
+
} else {
|
|
52
|
+
const opts = { budgetMs: 9000, samplePages: 3, lang: 'ru' };
|
|
53
|
+
|
|
54
|
+
const good = await serve(0);
|
|
55
|
+
let clean;
|
|
56
|
+
try { clean = await checkVisibility(good.url, opts); } finally { good.s.close(); }
|
|
57
|
+
ok('исправный сервер: проверка прошла', clean.ok === true);
|
|
58
|
+
const cleanIndex = clean.areas.find((a) => a.id === 'index');
|
|
59
|
+
ok('и карта для агентов зачтена', cleanIndex.score > 0);
|
|
60
|
+
|
|
61
|
+
const flaky = await serve(1);
|
|
62
|
+
let shaky;
|
|
63
|
+
try { shaky = await checkVisibility(flaky.url, opts); } finally { flaky.s.close(); }
|
|
64
|
+
ok('сервер с одним сбоем: проверка прошла', shaky.ok === true);
|
|
65
|
+
ok('к llms.txt обратились дважды, а не один раз', flaky.asked() >= 2);
|
|
66
|
+
const shakyIndex = shaky.areas.find((a) => a.id === 'index');
|
|
67
|
+
is('область карты дала тот же балл', shakyIndex.score, cleanIndex.score);
|
|
68
|
+
is('и общий балл совпал', shaky.score, clean.score);
|
|
69
|
+
|
|
70
|
+
// Постоянный отказ это не сбой сети: область честно остаётся неизмеренной.
|
|
71
|
+
const dead = await serve(99);
|
|
72
|
+
let never;
|
|
73
|
+
try { never = await checkVisibility(dead.url, opts); } finally { dead.s.close(); }
|
|
74
|
+
const neverIndex = never.areas.find((a) => a.id === 'index');
|
|
75
|
+
is('при постоянном отказе область не измерена', neverIndex.measured, false);
|
|
76
|
+
is('и балла у неё нет, а не ноль', neverIndex.score, null);
|
|
77
|
+
ok('и это не выдаётся за «у вас нет llms.txt»', !neverIndex.findings.some((f) => /Нет llms\.txt/.test(f.text)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(bad ? `\nПРОВАЛЕНО: ${bad}` : '\nодин сбой сети балл не меняет, постоянный отказ не считается ни в плюс, ни в минус');
|
|
81
|
+
process.exit(bad ? 1 : 0);
|
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} проверках, всё чисто`);
|
package/src/visibility.mjs
CHANGED
|
@@ -383,7 +383,32 @@ export async function checkVisibility(input, { budgetMs = 8500, lang = 'en', sam
|
|
|
383
383
|
const url = normaliseInput(input);
|
|
384
384
|
if (!url) return { ok: false, error: T.badInput };
|
|
385
385
|
const origin = new URL(url).origin; const host = new URL(url).host;
|
|
386
|
-
|
|
386
|
+
/*
|
|
387
|
+
* Служебные файлы читаются с одним повтором, и это не украшение.
|
|
388
|
+
*
|
|
389
|
+
* Один ответ решает судьбу целой области: robots.txt стоит 25 очков, llms.txt 15. До 16.09.2026
|
|
390
|
+
* повтора не было, и одна неудачная выборка меняла балл на пятнадцать пунктов. 15.09.2026
|
|
391
|
+
* avtokomissar-app.ru получил 52 на русском сайте и 67 на английском с разницей в две минуты,
|
|
392
|
+
* при том что движок, версия и параметры у обоих одинаковые. Владелец сайта читает такое
|
|
393
|
+
* расхождение как «вы врёте», и он прав.
|
|
394
|
+
*
|
|
395
|
+
* Повторяем только неопределённый ответ: таймаут, отказ защиты, ошибку сервера. Честные 404 и
|
|
396
|
+
* 410 значат, что файла правда нет, их переспрашивать незачем и это удвоило бы запросы почти
|
|
397
|
+
* на каждом сайте.
|
|
398
|
+
*/
|
|
399
|
+
const settled = (r) => r.ok || r.status === 404 || r.status === 410;
|
|
400
|
+
const once = (u, ms) => get(u, { timeout: Math.min(ms, left() - 300) });
|
|
401
|
+
const twice = async (u, ms) => {
|
|
402
|
+
const first = await once(u, ms);
|
|
403
|
+
if (settled(first) || left() < 1200) return first;
|
|
404
|
+
const again = await once(u, Math.min(ms, 2500));
|
|
405
|
+
return settled(again) ? again : first;
|
|
406
|
+
};
|
|
407
|
+
const [home, robotsRes, llmsRes] = await Promise.all([
|
|
408
|
+
once(url, 6000),
|
|
409
|
+
twice(`${origin}/robots.txt`, 4000),
|
|
410
|
+
twice(`${origin}/llms.txt`, 4000),
|
|
411
|
+
]);
|
|
387
412
|
if (!home.ok || !/html/i.test(home.type)) {
|
|
388
413
|
// Код 401/403/405/429 это бот-стена, а не сломанный сайт, и это находка сама по себе: та же
|
|
389
414
|
// стена, что развернула эту проверку, разворачивает и роботов ответных систем.
|