@operstack/audit 0.21.3 → 0.22.0
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/collect.mjs +14 -1
- package/src/test-flaky-files.mjs +81 -0
- package/src/test-ru-sources.mjs +41 -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.
|
|
3
|
+
"version": "0.22.0",
|
|
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-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",
|
|
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 && node src/test-ru-sources.mjs",
|
|
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/collect.mjs
CHANGED
|
@@ -52,6 +52,13 @@ async function follow(url, max = 5) {
|
|
|
52
52
|
|
|
53
53
|
const row = (id, group, label, status, value, comment = '') => ({ id, group, label, status, value, comment });
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Чем в тексте называют источник цифры, по-английски и по-русски. Список слово в слово совпадает
|
|
57
|
+
* с SOURCE_RE в visibility.mjs: два движка должны считать одно и то же, иначе балл в бесплатной
|
|
58
|
+
* проверке и в платном отчёте разойдётся на ровном месте.
|
|
59
|
+
*/
|
|
60
|
+
const SOURCE_RE = /(?<!\p{L})(according to|source:|sources:|data from|reported by|published by|registry|statistics office|central bank|по данным|источник:|источники:|согласно|по информации|по сведениям|росстат|центробанк|банк россии|росреестр|минфин|минэкономразвития)(?!\p{L})/giu;
|
|
61
|
+
|
|
55
62
|
function analysePage(url, html) {
|
|
56
63
|
const root = parse(html, { blockTextElements: { script: true, style: true, noscript: true } });
|
|
57
64
|
const attr = (sel, a) => root.querySelector(sel)?.getAttribute(a) ?? '';
|
|
@@ -147,7 +154,13 @@ function analysePage(url, html) {
|
|
|
147
154
|
const tables = (bodyHtml.match(/<table[\s>]/gi) || []).length;
|
|
148
155
|
// A source is named either in words, or the way careful writing actually does it: a link to
|
|
149
156
|
// somebody else's site sitting in the same paragraph as the figure it backs.
|
|
150
|
-
|
|
157
|
+
// Слова, которыми называют источник цифры. Список и границы слова те же, что в движке
|
|
158
|
+
// бесплатной проверки (visibility.mjs, SOURCE_RE): до 16.09.2026 здесь были только английские
|
|
159
|
+
// выражения, и русский сайт, честно написавший «по данным Росстата», получал ноль. Живой
|
|
160
|
+
// пользователь сказал об этом прямо: на русском сайте эти баллы недостижимы.
|
|
161
|
+
// Обычная граница слова \b для кириллицы не работает, она построена на латинице, поэтому
|
|
162
|
+
// границы заданы через отсутствие буквы рядом.
|
|
163
|
+
const sourcePhrases = (text.match(SOURCE_RE) || []).length;
|
|
151
164
|
const ownHostname = (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; } })();
|
|
152
165
|
// Абзацы, в которых вообще есть цифра. Без этого счётчика «цифры без источника» вменяется
|
|
153
166
|
// странице, где цифр нет ни одной, например политике конфиденциальности.
|
|
@@ -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);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Русский сайт, который честно называет источник цифры, получает за это балл.
|
|
4
|
+
*
|
|
5
|
+
* 16.09.2026 живой пользователь сказал прямо: «поиск источника цифр англоязычный, на русском
|
|
6
|
+
* сайте эти пять баллов недостижимы честно». Так и было: в движке платного аудита список слов
|
|
7
|
+
* состоял только из английских выражений, хотя в движке бесплатной проверки русские уже были.
|
|
8
|
+
* Два движка считали одно и то же по-разному.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { resolve, dirname } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const src = readFileSync(resolve(here, 'collect.mjs'), 'utf8');
|
|
16
|
+
const vis = readFileSync(resolve(here, 'visibility.mjs'), 'utf8');
|
|
17
|
+
|
|
18
|
+
let bad = 0;
|
|
19
|
+
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
20
|
+
|
|
21
|
+
const line = (text) => (text.match(/^const SOURCE_RE = .+$/m) || [''])[0];
|
|
22
|
+
ok('списки слов в обоих движках совпадают слово в слово', line(src) === line(vis) && line(src).length > 60);
|
|
23
|
+
|
|
24
|
+
const RE = new RegExp(line(src).replace(/^const SOURCE_RE = \//, '').replace(/\/giu;$/, ''), 'giu');
|
|
25
|
+
const count = (t) => (t.match(RE) || []).length;
|
|
26
|
+
|
|
27
|
+
for (const t of ['По данным Росстата, рынок вырос на 12%.',
|
|
28
|
+
'Источник: отчёт Банка России за 2025 год.',
|
|
29
|
+
'Согласно исследованию, доля выросла до 40%.',
|
|
30
|
+
'По информации Минфина, ставка снижена.',
|
|
31
|
+
'По сведениям Росреестра, сделок стало больше.'])
|
|
32
|
+
ok(`русский источник виден: ${t.slice(0, 34)}…`, count(t) > 0);
|
|
33
|
+
|
|
34
|
+
for (const t of ['According to Rosstat, the market grew 12%.', 'Source: Bank of Russia report.'])
|
|
35
|
+
ok(`английский по-прежнему виден: ${t.slice(0, 30)}…`, count(t) > 0);
|
|
36
|
+
|
|
37
|
+
for (const t of ['Мы выросли на 12% за год.', 'Наша команда работает с 2019 года.', 'Согласнее всех был директор.'])
|
|
38
|
+
ok(`пустая похвальба источником не считается: ${t.slice(0, 28)}…`, count(t) === 0);
|
|
39
|
+
|
|
40
|
+
console.log(bad ? `\nпровалов: ${bad}` : '\nвсё сходится');
|
|
41
|
+
process.exit(bad ? 1 : 0);
|
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
|
// стена, что развернула эту проверку, разворачивает и роботов ответных систем.
|