@operstack/audit 0.35.1 → 0.37.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/README.md +10 -0
- package/package.json +5 -3
- package/src/client-eye.mjs +339 -0
- package/src/collect.mjs +94 -24
- package/src/etalon.mjs +1 -1
- package/src/fix.mjs +1 -1
- package/src/i18n.mjs +37 -23
- package/src/index.mjs +2 -0
- package/src/narrative.mjs +78 -31
- package/src/pagekind.mjs +6 -0
- package/src/presentation.mjs +95 -0
- package/src/prompts.mjs +58 -17
- package/src/render.mjs +41 -25
- package/src/rendered.mjs +8 -3
- package/src/tasks-pdf.mjs +1 -1
- package/src/verify.mjs +19 -12
- package/src/test-agency-ids.mjs +0 -48
- package/src/test-agency.mjs +0 -158
- package/src/test-agent-surface.mjs +0 -46
- package/src/test-blocked.mjs +0 -100
- package/src/test-crawl-links.mjs +0 -87
- package/src/test-demand.mjs +0 -243
- package/src/test-etalon.mjs +0 -42
- package/src/test-fix.mjs +0 -115
- package/src/test-flaky-files.mjs +0 -81
- package/src/test-foundation.mjs +0 -126
- package/src/test-handover.mjs +0 -148
- package/src/test-i18n.mjs +0 -68
- package/src/test-narrative.mjs +0 -117
- package/src/test-overall.mjs +0 -236
- package/src/test-planted.mjs +0 -114
- package/src/test-prompts.mjs +0 -142
- package/src/test-prospect-count.mjs +0 -12
- package/src/test-redirect-host.mjs +0 -63
- package/src/test-rendered.mjs +0 -125
- package/src/test-report-wording.mjs +0 -35
- package/src/test-ru-report.mjs +0 -95
- package/src/test-ru-sources.mjs +0 -41
- package/src/test-sample-fairness.mjs +0 -50
- package/src/test-sample.mjs +0 -91
- package/src/test-scores.mjs +0 -75
- package/src/test-shopify-store.mjs +0 -159
- package/src/test-sitemap.mjs +0 -91
- package/src/test-unmeasured.mjs +0 -173
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Магазин на Shopify, как brainmd.com 12.10.2026. Прогон 0.33.3 дал пять неправд о живом сайте:
|
|
4
|
-
* 1. «язык не указан», хотя <html> разбит на строки и lang="en" в нём стоит;
|
|
5
|
-
* 2. «в карте 349 страниц» при 1 357: из десяти файлов карты прочитаны шесть, статьи пропущены;
|
|
6
|
-
* 3. «CMS не измерено», хотя в коде Shopify.shop и cdn.shopify.com;
|
|
7
|
-
* 4. в выборку попали книги и диски вместо основных товаров, и FAQPage «не найдена»;
|
|
8
|
-
* 5. балл видимости не посчитан: в выборку попали agents.md и вход в аккаунт.
|
|
9
|
-
* Второй прогон того же дня нашёл ещё пять:
|
|
10
|
-
* 6. «нет sameAs», хотя в JSON-LD семь профилей, только слеши записаны как \/;
|
|
11
|
-
* 7. «мёртвая ссылка» в llms.txt с хвостовой обратной кавычкой из markdown;
|
|
12
|
-
* 8. «цены нет» при ценах на каждом товаре; пустая корзина $0.00 при этом не цена;
|
|
13
|
-
* 9. «на первом экране нечего нажать»: Add to cart не считалось, а меню съедало 15% разметки;
|
|
14
|
-
* 10. свой домен с точкой на конце в списке чужих хостов llms.txt.
|
|
15
|
-
* Здесь такой же магазин поднимается локально, и каждая из пяти неправд проверяется отдельно.
|
|
16
|
-
*/
|
|
17
|
-
import { createServer } from 'node:http';
|
|
18
|
-
import { lookup } from 'node:dns/promises';
|
|
19
|
-
import { collect, detectStack, homeLinkRank } from './collect.mjs';
|
|
20
|
-
import { checkVisibility, linksInText } from './visibility.mjs';
|
|
21
|
-
import { localiseChecks } from './i18n.mjs';
|
|
22
|
-
import { draftNarrative } from './narrative.mjs';
|
|
23
|
-
|
|
24
|
-
let bad = 0;
|
|
25
|
-
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
26
|
-
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
27
|
-
|
|
28
|
-
// ---- движок по следам в коде
|
|
29
|
-
is('Shopify по Shopify.shop', detectStack('<script>Shopify.shop = "x.myshopify.com";</script>'), 'Shopify');
|
|
30
|
-
is('Shopify по cdn.shopify.com', detectStack('<link href="//cdn.shopify.com/s/files/1/a.css">'), 'Shopify');
|
|
31
|
-
is('WordPress по wp-content', detectStack('<img src="/wp-content/uploads/a.jpg">'), 'WordPress');
|
|
32
|
-
is('без следов не угадываем', detectStack('<p>Shopify is a platform we like</p>'), '');
|
|
33
|
-
|
|
34
|
-
// ---- 7, 10. Адреса из markdown без хвостов
|
|
35
|
-
is('хвосты markdown отрезаны', linksInText('See `https://x.com/.well-known/ucp`. Home: https://x.com. Also (https://x.com/a), "https://x.com/b"'),
|
|
36
|
-
['https://x.com/.well-known/ucp', 'https://x.com', 'https://x.com/a', 'https://x.com/b']);
|
|
37
|
-
|
|
38
|
-
// ---- порядок по ссылкам главной
|
|
39
|
-
{
|
|
40
|
-
const r = homeLinkRank('<a href="/products/a">1</a><a href="/products/a">2</a><a href="https://www.x.com/products/b/">3</a><a href="https://other.com/products/c">4</a>', 'https://x.com/', new Set(['x.com', 'www.x.com']));
|
|
41
|
-
is('ссылки главной посчитаны', [r.get('https://x.com/products/a/'), r.get('https://www.x.com/products/b/'), r.has('https://other.com/products/c/')], [2, 1, false]);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const BOOKS = Array.from({ length: 12 }, (_, i) => `/products/book-${i + 1}`);
|
|
45
|
-
const MAIN = ['/products/omega-power', '/products/memory-boost'];
|
|
46
|
-
const ARTICLES = Array.from({ length: 980 }, (_, i) => `/blogs/news/post-${i + 1}`);
|
|
47
|
-
const PAGES = ['/pages/contact', '/pages/about'];
|
|
48
|
-
const CHILDREN = ['agentic_discovery', 'products_1?from=1&to=2', 'pages_1', 'collections_1', 'blogs_1', 'articles_1', 'metaobject_pages_1', 'metaobject_pages_2', 'metaobject_pages_3'];
|
|
49
|
-
|
|
50
|
-
const shopifyHead = (title, schema = '') => `<!doctype html>
|
|
51
|
-
<html
|
|
52
|
-
class="no-js market-us "
|
|
53
|
-
lang="en"
|
|
54
|
-
|
|
55
|
-
>
|
|
56
|
-
<head><title>${title} | Brain Store supplements</title>
|
|
57
|
-
<meta name="description" content="A description of the page that is long enough to count as a real one for the check.">
|
|
58
|
-
<link rel="stylesheet" href="//cdn.shopify.com/s/files/1/theme.css">
|
|
59
|
-
<script>var Shopify = Shopify || {}; Shopify.shop = "x09u0a-0i.myshopify.com";</script>${schema}</head>`;
|
|
60
|
-
const sameAs = '<script type="application/ld+json">{"@context":"https:\\/\\/schema.org","@type":"Organization","name":"Brain Store","sameAs":["https:\\/\\/www.instagram.com\\/brainstore\\/","https:\\/\\/www.youtube.com\\/brainstore","https:\\/\\/www.facebook.com\\/brainstore"]}</script>';
|
|
61
|
-
const cartDrawer = '<div class="drawer">Cart 0. Spend $0.00 more to reach free shipping!</div>';
|
|
62
|
-
const bigMenu = `<header><div class="menu">${'<div class="item"><img src="/i.png" alt=""><span class="x"></span></div>'.repeat(400)}</div></header>`;
|
|
63
|
-
const buyBox = `<div class="gallery">${'<img src="/g.jpg" alt="">'.repeat(60)}</div><div class="buy"><p>$39.95</p><button type="submit" name="add">Add To Cart</button></div>`;
|
|
64
|
-
const faq = '<script type="application/ld+json">{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[]}</script><script type="application/ld+json">{"@context":"https://schema.org","@type":"Product","name":"X"}</script>';
|
|
65
|
-
const body = (h1, links = []) => `<body><main><h1>${h1}</h1>${'<p>Plain paragraph about the product, what it does and the price.</p>'.repeat(8)}
|
|
66
|
-
<nav>${links.map((l) => `<a href="${l}">${l}</a>`).join('')}</nav></main></body></html>`;
|
|
67
|
-
const urlset = (base, list) => `<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${list.map((p) => `<url><loc>${base}${p}</loc></url>`).join('')}</urlset>`;
|
|
68
|
-
|
|
69
|
-
const hits = [];
|
|
70
|
-
const server = createServer((req, res) => {
|
|
71
|
-
hits.push(req.url);
|
|
72
|
-
const base = `http://${req.headers.host}`;
|
|
73
|
-
const xml = (s) => { res.writeHead(200, { 'content-type': 'application/xml' }); res.end(s); };
|
|
74
|
-
const html = (s) => { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(s); };
|
|
75
|
-
const u = req.url;
|
|
76
|
-
if (u === '/robots.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(`User-agent: *\nDisallow: /cart\nSitemap: ${base}/sitemap.xml\n`); }
|
|
77
|
-
if (u === '/sitemap.xml') return xml(`<?xml version="1.0"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${CHILDREN.map((c) => `<sitemap><loc>${base}/sitemap_${c.replace(/\?.*/, '')}.xml${c.includes('?') ? `?${c.split('?')[1]}` : ''}</loc></sitemap>`).join('')}</sitemapindex>`);
|
|
78
|
-
if (u === '/sitemap_agentic_discovery.xml') return xml(urlset(base, ['/agents.md']));
|
|
79
|
-
// Shopify отдаёт товары только с обоими параметрами: & в адресе значит пустую карту.
|
|
80
|
-
if (u.startsWith('/sitemap_products_1.xml')) return xml(urlset(base, u === '/sitemap_products_1.xml?from=1&to=2' ? [...BOOKS, ...MAIN] : []));
|
|
81
|
-
if (u === '/sitemap_pages_1.xml') return xml(urlset(base, PAGES));
|
|
82
|
-
if (u === '/sitemap_articles_1.xml') return xml(urlset(base, ARTICLES));
|
|
83
|
-
if (u.startsWith('/sitemap_')) return xml(urlset(base, []));
|
|
84
|
-
if (u === '/agents.md') { res.writeHead(200, { 'content-type': 'text/markdown' }); return res.end('# Agents\n'); }
|
|
85
|
-
if (u.startsWith('/customer_authentication/')) return; // висит, как вход в аккаунт Shopify
|
|
86
|
-
if (u === '/llms.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(`# Brain Store\n\nShop: ${base}.\nAgents: \`${base}/.well-known/ucp\`.\n- [Omega](${base}${MAIN[0]})\n- [Memory](${base}${MAIN[1]})\n- [Contact](${base}/pages/contact)\n- [Shop app](https://shop.app/brainstore)\n`); }
|
|
87
|
-
if (u === '/.well-known/ucp') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{}'); }
|
|
88
|
-
if (u === '/') return html(shopifyHead('Home', sameAs) + body('Brain Store', [MAIN[0], MAIN[0], MAIN[0], MAIN[1], MAIN[1], '/pages/contact', '/customer_authentication/redirect']));
|
|
89
|
-
if (MAIN.includes(u)) return html(shopifyHead(u, faq) + `<body>${cartDrawer}${bigMenu}<main><h1>${u}</h1>${buyBox}${'<p>Plain paragraph about the product, what it does and how to take it.</p>'.repeat(40)}</main></body></html>`);
|
|
90
|
-
if (PAGES.includes(u)) return html(shopifyHead(u) + `<body>${cartDrawer}` + body(u).slice('<body>'.length));
|
|
91
|
-
if (BOOKS.includes(u) || u.startsWith('/blogs/')) return html(shopifyHead(u) + body(u));
|
|
92
|
-
res.writeHead(404, { 'content-type': 'text/plain' }); res.end('no');
|
|
93
|
-
});
|
|
94
|
-
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
95
|
-
// Бесплатная проверка не берёт 127.0.0.1 (это не публичный адрес), поэтому живая часть идёт через lvh.me.
|
|
96
|
-
let dnsOk = true;
|
|
97
|
-
try { dnsOk = (await lookup('lvh.me')).address === '127.0.0.1'; } catch { dnsOk = false; }
|
|
98
|
-
if (!dnsOk) { console.log('ok пропуск живой части: lvh.me не указывает на 127.0.0.1 (нет сети)'); server.close(); process.exit(bad ? 1 : 0); }
|
|
99
|
-
const base = `http://lvh.me:${server.address().port}`;
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
const a = await collect(`${base}/`, { pages: 6, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
|
|
103
|
-
const check = (id) => a.checks.find((c) => c.id === id) || {};
|
|
104
|
-
const row = (label) => (a.overview.rows.find((r) => r[0] === label) || [])[1];
|
|
105
|
-
|
|
106
|
-
// 1. Язык
|
|
107
|
-
is('1. язык прочитан из многострочного <html>', a.meta.language, 'en');
|
|
108
|
-
|
|
109
|
-
// 2. Карта сайта
|
|
110
|
-
const total = BOOKS.length + MAIN.length + PAGES.length + ARTICLES.length + 1; // + agents.md
|
|
111
|
-
is('2. прочитаны все десять файлов карты', check('sitemap').value, `${total} URL(s) in ${base}/sitemap.xml`);
|
|
112
|
-
is('2. в обзоре полное число', row('Pages in sitemap'), String(total));
|
|
113
|
-
ok('2. товары прочитаны по адресу с &, а не с &', hits.includes('/sitemap_products_1.xml?from=1&to=2'));
|
|
114
|
-
|
|
115
|
-
// 2. Если карта прочитана не вся, в обзоре это сказано, а не выдано за целое.
|
|
116
|
-
const part = await collect(`${base}/`, { pages: 2, pageDelayMs: 0, sitemapMaxFiles: 3, log: () => {}, visibility: { ok: false, error: 'skipped in test' }, rendered: false });
|
|
117
|
-
const partRow = (part.overview.rows.find((r) => r[0] === 'Pages in sitemap') || [])[1] || '';
|
|
118
|
-
ok(`2. частичная цифра названа частичной (${partRow})`, /^at least \d+: 3 of 10 sitemap files were read/.test(partRow));
|
|
119
|
-
|
|
120
|
-
// 3. Движок
|
|
121
|
-
is('3. Shopify в обзоре', row('CMS / stack'), 'Shopify');
|
|
122
|
-
is('3. Shopify в проверке', check('cms').value, 'Shopify (detected from the page code)');
|
|
123
|
-
is('3. по-русски', localiseChecks([check('cms')], 'ru')[0].value, 'Shopify (определён по коду страницы)');
|
|
124
|
-
|
|
125
|
-
// 4. Выборка
|
|
126
|
-
const read = a.sample.filter((p) => p.title !== undefined).map((p) => new URL(p.url).pathname);
|
|
127
|
-
ok(`4. основные товары с главной в выборке (${read.join(', ')})`, MAIN.every((p) => read.includes(p)));
|
|
128
|
-
ok('4. чаще упомянутый на главной идёт раньше', read.indexOf(MAIN[0]) < read.indexOf(MAIN[1]));
|
|
129
|
-
ok('4. книги не вытеснили товары', read.filter((p) => p.startsWith('/products/book-')).length <= 1);
|
|
130
|
-
ok('4. agents.md не читается как страница', !read.includes('/agents.md'));
|
|
131
|
-
is('4. FAQPage найдена', check('faq-schema').status, 'ok');
|
|
132
|
-
|
|
133
|
-
// 6. Профили из sameAs со слешами \/
|
|
134
|
-
is('6. профили прочитаны', check('trust-profiles').value, '3: instagram.com, youtube.com, facebook.com');
|
|
135
|
-
|
|
136
|
-
// 7, 10. llms.txt
|
|
137
|
-
ok(`7. адрес с обратной кавычкой не мёртвый (${check('llms-health').value})`, check('llms-health').status === 'ok');
|
|
138
|
-
// Точку после своего домена с портом URL не разберёт, поэтому сам случай brainmd.com. проверен выше на linksInText.
|
|
139
|
-
is('10. свой домен с точкой не чужой хост', check('llms').value, '6 link(s), 1 to other hosts');
|
|
140
|
-
|
|
141
|
-
// 8. Цена: из выборки, и пустая корзина ценой не считается
|
|
142
|
-
const priced = a.sample.filter((p) => p.priceShown).map((p) => new URL(p.url).pathname);
|
|
143
|
-
ok(`8. цена найдена на товарах и только на них (${priced.join(', ')})`, MAIN.every((p) => priced.includes(p)) && !priced.includes('/pages/contact'));
|
|
144
|
-
const drafted = draftNarrative(a, { lang: 'en' });
|
|
145
|
-
ok(`8. строка «цена видна» не пишет «цены нет» (${drafted.conversion.rows.find((r) => /price/i.test(r[0]))?.[1]})`, /^yes: a price is shown on 2 of \d+ pages/.test(drafted.conversion.rows.find((r) => /price/i.test(r[0]))?.[1] || ''));
|
|
146
|
-
|
|
147
|
-
// 9. Призыв на первом экране: Add To Cart после огромного меню и галереи
|
|
148
|
-
ok(`9. Add to cart на товаре засчитан (${check('conv-cta').value})`, /^2 of /.test(check('conv-cta').value) // два товара; ссылки главной стоят в <nav>, это не первый экран
|
|
149
|
-
&& a.sample.filter((p) => MAIN.some((m) => p.url.endsWith(m))).every((p) => p.ctaFirstScreen === true));
|
|
150
|
-
|
|
151
|
-
// 5. Балл видимости
|
|
152
|
-
const v = await checkVisibility(`${base}/`, { lang: 'en' });
|
|
153
|
-
ok(`5. балл посчитан (${v.ok ? v.score : v.error})`, v.ok && Number.isFinite(v.score));
|
|
154
|
-
const vRead = (v.sample || []).map((p) => new URL(p.url).pathname);
|
|
155
|
-
ok(`5. в выборке нет agents.md и входа в аккаунт (${vRead.join(', ')})`, vRead.length >= 3 && !vRead.some((p) => p === '/agents.md' || p.startsWith('/customer_authentication')));
|
|
156
|
-
} finally { server.close(); server.closeAllConnections?.(); }
|
|
157
|
-
|
|
158
|
-
if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
|
|
159
|
-
console.log('\nвсё сходится');
|
package/src/test-sitemap.mjs
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/** Обход карт сайта: ограничители и честность отчёта.
|
|
3
|
-
*
|
|
4
|
-
* 14.09.2026 бесплатная проверка не собрала отчёт по whitewill.ru. В его sitemap.xml 79 вложенных
|
|
5
|
-
* карт, обход заходил в каждую по 15 секунд, и прогон умирал по таймауту, не дочитав до страниц.
|
|
6
|
-
* Тесты поднимают такой же сайт локально, чтобы это не вернулось. */
|
|
7
|
-
import { createServer } from 'node:http';
|
|
8
|
-
import { __readSitemap } from './collect.mjs';
|
|
9
|
-
import { localiseChecks } from './i18n.mjs';
|
|
10
|
-
|
|
11
|
-
let bad = 0;
|
|
12
|
-
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
13
|
-
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
14
|
-
|
|
15
|
-
const index = (hrefs) => `<?xml version="1.0"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${hrefs.map((h) => `<sitemap><loc>${h}</loc></sitemap>`).join('')}</sitemapindex>`;
|
|
16
|
-
const urlset = (hrefs) => `<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${hrefs.map((h) => `<url><loc>${h}</loc></url>`).join('')}</urlset>`;
|
|
17
|
-
|
|
18
|
-
/** Сервер отвечает медленно, как настоящий сайт на другом континенте. */
|
|
19
|
-
const start = (delayMs, nested) => new Promise((resolve) => {
|
|
20
|
-
const server = createServer(async (req, res) => {
|
|
21
|
-
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
|
|
22
|
-
const base = `http://127.0.0.1:${server.address().port}`;
|
|
23
|
-
res.setHeader('content-type', 'application/xml');
|
|
24
|
-
if (req.url === '/sitemap.xml') return res.end(index(Array.from({ length: nested }, (_, i) => `${base}/map-${i}.xml`)));
|
|
25
|
-
if (req.url === '/plain.xml') return res.end(urlset([`${base}/page`, `${base}/feed.xml`]));
|
|
26
|
-
const i = (req.url.match(/map-(\d+)/) || [])[1] ?? '0';
|
|
27
|
-
return res.end(urlset([`${base}/p-${i}-a`, `${base}/p-${i}-b`]));
|
|
28
|
-
});
|
|
29
|
-
server.listen(0, '127.0.0.1', () => resolve({ server, base: `http://127.0.0.1:${server.address().port}` }));
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
// 1. Семьдесят девять вложенных карт: читаем несколько файлов, а не все, и говорим об этом.
|
|
33
|
-
{
|
|
34
|
-
const { server, base } = await start(30, 79);
|
|
35
|
-
const began = Date.now();
|
|
36
|
-
const r = await __readSitemap(`${base}/sitemap.xml`, { maxFiles: 6, budgetMs: 45000 });
|
|
37
|
-
const took = Date.now() - began;
|
|
38
|
-
is('прочитано ровно столько файлов, сколько разрешено', r.filesRead, 6);
|
|
39
|
-
is('остановились по числу файлов', r.stopped, 'files');
|
|
40
|
-
is('нашли все вложенные карты', r.nestedFound, 79);
|
|
41
|
-
ok('непрочитанные файлы посчитаны', r.pending === 74);
|
|
42
|
-
ok('адресов хватает на выборку страниц', r.urls.length >= 10);
|
|
43
|
-
ok(`уложились в разумное время (${took} мс)`, took < 5000);
|
|
44
|
-
server.close();
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// 2. Маленький сайт читается целиком, и тогда про ограничители в отчёте ни слова.
|
|
48
|
-
{
|
|
49
|
-
const { server, base } = await start(0, 2);
|
|
50
|
-
const r = await __readSitemap(`${base}/sitemap.xml`);
|
|
51
|
-
is('маленькая карта прочитана целиком', r.stopped, '');
|
|
52
|
-
is('файлов прочитано: индекс и две карты', r.filesRead, 3);
|
|
53
|
-
is('собраны все адреса', r.urls.length, 4);
|
|
54
|
-
server.close();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// 3. Из обычного urlset вглубь не спускаемся, даже если адрес заканчивается на .xml.
|
|
58
|
-
// Прежнее условие из-за приоритета операторов читало `A || (B && C && D)` и уходило в рекурсию.
|
|
59
|
-
{
|
|
60
|
-
const { server, base } = await start(0, 2);
|
|
61
|
-
const r = await __readSitemap(`${base}/plain.xml`);
|
|
62
|
-
is('из urlset рекурсии нет', r.filesRead, 1);
|
|
63
|
-
is('ссылка на .xml осталась адресом страницы', r.urls.length, 2);
|
|
64
|
-
server.close();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// 4. Общий бюджет времени соблюдается, даже если файлов мало, а сайт медленный.
|
|
68
|
-
{
|
|
69
|
-
const { server, base } = await start(200, 40);
|
|
70
|
-
const began = Date.now();
|
|
71
|
-
const r = await __readSitemap(`${base}/sitemap.xml`, { maxFiles: 100, budgetMs: 700 });
|
|
72
|
-
const took = Date.now() - began;
|
|
73
|
-
is('остановились по времени', r.stopped, 'time');
|
|
74
|
-
ok(`бюджет времени соблюдён (${took} мс)`, took < 2500);
|
|
75
|
-
server.close();
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// 5. По-русски усечённый обход называется словами, а не «не отдаётся, код 1200».
|
|
79
|
-
// Локализатор подбирает строку регулярным выражением, поэтому новая формулировка без своей
|
|
80
|
-
// ветки молча проваливается в числовую. Ровно это уже случалось с нулями в отчёте.
|
|
81
|
-
{
|
|
82
|
-
const en = [{ id: 'sitemap', group: 'technical', label: 'XML sitemap', status: 'ok', value: '1200 URL(s) read from 6 of 79 sitemap file(s) in https://example.ru/sitemap.xml', comment: '' }];
|
|
83
|
-
const ru = localiseChecks(en, 'ru')[0].value;
|
|
84
|
-
ok(`усечение названо словами: "${ru}"`, /прочитано из 6 файлов карты, всего файлов 79/.test(ru));
|
|
85
|
-
ok('и это не выдано за код ответа', !/не отдаётся/.test(ru));
|
|
86
|
-
const whole = localiseChecks([{ ...en[0], value: '1200 URL(s) in https://example.ru/sitemap.xml' }], 'ru')[0].value;
|
|
87
|
-
ok(`целая карта по-прежнему читается нормально: "${whole}"`, /в карте сайта/.test(whole));
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
91
|
-
console.log('\nвсе тесты обхода карт прошли');
|
package/src/test-unmeasured.mjs
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Не прочитали robots.txt, значит не знаем, а не «всё отлично».
|
|
4
|
-
*
|
|
5
|
-
* 15.09.2026 проверка ставила 25 из 25 и писала «Все 14 роботов ИИ допущены на сайт» сайту, чей
|
|
6
|
-
* robots.txt отдал 403. Файла нет, значит запретов нет, значит отлично. Владелец получал высшую
|
|
7
|
-
* оценку за файл, который мы не открывали. Незнание это не хорошая новость.
|
|
8
|
-
*
|
|
9
|
-
* Здесь держится вся цепочка: движок помечает область неизмеренной и переносит балл на сто по
|
|
10
|
-
* остальным, проверка сходимости это понимает, отчёт печатает слово вместо числа и объясняет,
|
|
11
|
-
* из чего сложился балл.
|
|
12
|
-
*
|
|
13
|
-
* Сервер поднимается на loopback, а зовётся по имени lvh.me: оно публично указывает на 127.0.0.1,
|
|
14
|
-
* и так проверка проходит свой же запрет на частные адреса честно, без лазейки в коде продукта.
|
|
15
|
-
*/
|
|
16
|
-
import { createServer } from 'node:http';
|
|
17
|
-
import { readFileSync } from 'node:fs';
|
|
18
|
-
import { lookup } from 'node:dns/promises';
|
|
19
|
-
import { checkVisibility } from './visibility.mjs';
|
|
20
|
-
import { verifyScores } from './collect.mjs';
|
|
21
|
-
import { toHtml, overallSummary } from './render.mjs';
|
|
22
|
-
|
|
23
|
-
let bad = 0;
|
|
24
|
-
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
25
|
-
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
26
|
-
|
|
27
|
-
const PAGE = `<!doctype html><html lang="ru"><head><title>Пример</title><meta name="description" content="Описание"></head>
|
|
28
|
-
<body><main><h1>Пример сайта</h1>${'<p>Обычный абзац текста про товар и цену, без цифр, чтобы было что цитировать. </p>'.repeat(40)}</main></body></html>`;
|
|
29
|
-
|
|
30
|
-
const serve = ({ robots, status = null, llmsStatus = 404 }) => new Promise((resolve) => {
|
|
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; }
|
|
33
|
-
if (req.url.startsWith('/robots.txt')) {
|
|
34
|
-
if (status) { res.writeHead(status, { 'content-type': 'text/html' }); res.end('<html>нет</html>'); return; }
|
|
35
|
-
if (robots === null) { res.writeHead(403, { 'content-type': 'text/html' }); res.end('<html>denied</html>'); return; }
|
|
36
|
-
res.writeHead(200, { 'content-type': 'text/plain' }); res.end(robots); return;
|
|
37
|
-
}
|
|
38
|
-
if (req.url === '/' ) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(PAGE); return; }
|
|
39
|
-
res.writeHead(404, { 'content-type': 'text/plain' }); res.end('no');
|
|
40
|
-
});
|
|
41
|
-
s.listen(0, '127.0.0.1', () => resolve({ s, url: `http://lvh.me:${s.address().port}/` }));
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
let dnsOk = true;
|
|
45
|
-
try { const a = await lookup('lvh.me'); dnsOk = a.address === '127.0.0.1'; } catch { dnsOk = false; }
|
|
46
|
-
|
|
47
|
-
if (!dnsOk) {
|
|
48
|
-
console.log('ПРОПУЩЕНО: lvh.me не указывает на 127.0.0.1 с этой машины, сквозная часть не прогнана');
|
|
49
|
-
} else {
|
|
50
|
-
// ---- robots.txt закрыт: область не измерена, балл по остальным
|
|
51
|
-
const { s, url } = await serve({ robots: null });
|
|
52
|
-
let v;
|
|
53
|
-
try { v = await checkVisibility(url, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s.close(); }
|
|
54
|
-
ok('проверка прошла (главная читается)', v.ok === true);
|
|
55
|
-
const access = v.areas.find((a) => a.id === 'access');
|
|
56
|
-
is('область доступа помечена неизмеренной', access.measured, false);
|
|
57
|
-
is('и балла у неё нет, а не ноль и не 25', access.score, null);
|
|
58
|
-
ok('первая находка говорит, что файл не прочитан', /не дал этой проверке прочитать robots\.txt/.test(access.findings[0].text));
|
|
59
|
-
ok('и что это не хорошая новость', /не хорошая новость/.test(access.findings[0].text));
|
|
60
|
-
ok('и не пишет «все роботы допущены»', !access.findings.some((f) => /допущены на сайт/.test(f.text)));
|
|
61
|
-
const rest = v.areas.filter((a) => a.id !== 'access');
|
|
62
|
-
const sum = rest.reduce((t, a) => t + a.score, 0);
|
|
63
|
-
const max = rest.reduce((t, a) => t + a.max, 0);
|
|
64
|
-
is('остальные области считаются из 75', max, 75);
|
|
65
|
-
is('балл это доля измеренного, перенесённая на сто', v.score, Math.round((sum / max) * 100));
|
|
66
|
-
ok('в исправлениях нет пункта про непрочитанный файл', !v.fixes.some((f) => f.id === 'robots-unreadable'));
|
|
67
|
-
|
|
68
|
-
// ---- сходимость: проверка отчёта понимает неизмеренную область
|
|
69
|
-
const problems = verifyScores({ checks: [], scores: {}, overall: { score: v.score, grade: v.grade, areas: v.areas } }).filter((p) => p.startsWith('overall:'));
|
|
70
|
-
is('проверка сходимости не спорит с движком', problems, []);
|
|
71
|
-
|
|
72
|
-
// ---- отчёт: слово вместо числа и честная подпись
|
|
73
|
-
const fixture = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
74
|
-
fixture.meta.lang = 'ru';
|
|
75
|
-
fixture.overall = { score: v.score, grade: v.grade, areas: v.areas, source: 'visibility', basis: { pages: 1, sitemapRead: false, sitemapUnchecked: false } };
|
|
76
|
-
const html = toHtml(fixture, null);
|
|
77
|
-
// Полосы вместо таблицы с 17.09.2026: сторож тот же, неизмеренная область говорит словом.
|
|
78
|
-
const bars = html.match(/<div class="areas-bars">[\s\S]*?<\/div>\s*(?=<h3|<div class="verdict")/)[0];
|
|
79
|
-
ok('в областях стоит слово, а не null', /class="na">не измерялось</.test(bars) && !/null/.test(bars));
|
|
80
|
-
const note = (html.match(/overall-note">([^<]*)</) || [])[1] || '';
|
|
81
|
-
ok('подпись говорит, сколько областей измерено', /Из пяти областей ниже измерены 4/.test(note));
|
|
82
|
-
ok('и из скольких очков', new RegExp(`${sum} из 75`).test(note));
|
|
83
|
-
ok('и что неизмеренное не в минус', /не зачтена ни в плюс, ни в минус/.test(note));
|
|
84
|
-
ok('старой фразы «пять областей дают в сумме ровно» нет', !/дают в сумме ровно/.test(note));
|
|
85
|
-
|
|
86
|
-
fixture.meta.lang = 'en';
|
|
87
|
-
const en = (toHtml(fixture, null).match(/overall-note">([^<]*)</) || [])[1] || '';
|
|
88
|
-
ok('английская подпись тоже честная', /4 could be measured/.test(en) && /not counted for or against/.test(en));
|
|
89
|
-
|
|
90
|
-
// ---- письмо берёт тот же блок: null остаётся null, слово едет рядом
|
|
91
|
-
const head = overallSummary(fixture.overall, { lang: 'ru' });
|
|
92
|
-
is('в блоке для письма область без балла', head.areas.find((a) => a.id === 'access').score, null);
|
|
93
|
-
is('и слово для неё передано', head.notMeasured, 'не измерялось');
|
|
94
|
-
|
|
95
|
-
// ---- контроль: с прочитанным robots.txt всё как раньше
|
|
96
|
-
const { s: s2, url: url2 } = await serve({ robots: 'User-agent: *\nDisallow: /admin/\n' });
|
|
97
|
-
let v2;
|
|
98
|
-
try { v2 = await checkVisibility(url2, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s2.close(); }
|
|
99
|
-
const access2 = v2.areas.find((a) => a.id === 'access');
|
|
100
|
-
is('с прочитанным файлом область измерена', access2.measured, true);
|
|
101
|
-
is('и оценена полностью', access2.score, 25);
|
|
102
|
-
is('и балл это прямая сумма', v2.score, v2.areas.reduce((t, a) => t + a.score, 0));
|
|
103
|
-
ok('подпись в отчёте прежняя', /дают в сумме ровно/.test((toHtml({ ...fixture, meta: { ...fixture.meta, lang: 'ru' }, overall: { ...fixture.overall, score: v2.score, areas: v2.areas } }, null).match(/overall-note">([^<]*)</) || [])[1] || ''));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// ---- файла нет это ОТВЕТ, а не незнание
|
|
107
|
-
//
|
|
108
|
-
// Первая версия правки считала неизмеренным любой неуспех, и сайт без robots.txt терял 25
|
|
109
|
-
// заслуженных баллов: по стандарту отсутствие файла означает, что не запрещено ничего.
|
|
110
|
-
// Поймано на живом example.com сразу после выкладки: балл упал с 30 до 7.
|
|
111
|
-
for (const code of [404, 410]) {
|
|
112
|
-
const { s: s3, url: url3 } = await serve({ status: code });
|
|
113
|
-
let v3;
|
|
114
|
-
try { v3 = await checkVisibility(url3, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s3.close(); }
|
|
115
|
-
const a3 = v3.areas.find((a) => a.id === 'access');
|
|
116
|
-
is(`robots.txt ${code}: область измерена`, a3.measured, true);
|
|
117
|
-
is(`robots.txt ${code}: и оценена полностью`, a3.score, 25);
|
|
118
|
-
ok(`robots.txt ${code}: сказано, что файла нет`, /Файла robots\.txt на сайте нет/.test(a3.findings[0].text));
|
|
119
|
-
ok(`robots.txt ${code}: и что это значит «не запрещено ничего»`, /не запрещено ничего/.test(a3.findings[0].text));
|
|
120
|
-
ok(`robots.txt ${code}: не назвали это непрочитанным`, !a3.findings.some((f) => f.id === 'robots-unreadable'));
|
|
121
|
-
is(`robots.txt ${code}: балл это прямая сумма`, v3.score, v3.areas.reduce((t, a) => t + a.score, 0));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ---- а отказ и сбой это по-прежнему незнание
|
|
125
|
-
for (const code of [401, 403, 429, 500]) {
|
|
126
|
-
const { s: s4, url: url4 } = await serve({ status: code });
|
|
127
|
-
let v4;
|
|
128
|
-
try { v4 = await checkVisibility(url4, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s4.close(); }
|
|
129
|
-
const a4 = v4.areas.find((a) => a.id === 'access');
|
|
130
|
-
is(`robots.txt ${code}: область не измерена`, a4.measured, false);
|
|
131
|
-
ok(`robots.txt ${code}: код назван в тексте`, new RegExp(`код ${code}`).test(a4.findings[0].text));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ---- llms.txt: та же граница между «нет файла» и «не дали файл»
|
|
135
|
-
//
|
|
136
|
-
// Сайт за защитой отдавал 403 на llms.txt, и мы писали владельцу «у вас нет карты для агентов»
|
|
137
|
-
// с нулём баллов. Файл мог быть, мы его просто не получили.
|
|
138
|
-
{
|
|
139
|
-
const cases = [[404, 'missing'], [410, 'missing'], [403, 'unknown'], [429, 'unknown'], [500, 'unknown']];
|
|
140
|
-
for (const [code, kind] of cases) {
|
|
141
|
-
const { s: s5, url: url5 } = await serve({ robots: 'User-agent: *\nDisallow: /admin/\n', llmsStatus: code });
|
|
142
|
-
let v5;
|
|
143
|
-
try { v5 = await checkVisibility(url5, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s5.close(); }
|
|
144
|
-
const a5 = v5.areas.find((a) => a.id === 'index');
|
|
145
|
-
if (kind === 'missing') {
|
|
146
|
-
is(`llms.txt ${code}: область измерена`, a5.measured, true);
|
|
147
|
-
is(`llms.txt ${code}: и это честный ноль`, a5.score, 0);
|
|
148
|
-
ok(`llms.txt ${code}: находка называет отсутствие`, a5.findings.some((f) => f.id === 'llms-missing'));
|
|
149
|
-
} else {
|
|
150
|
-
is(`llms.txt ${code}: область не измерена`, a5.measured, false);
|
|
151
|
-
is(`llms.txt ${code}: балла нет, а не ноль`, a5.score, null);
|
|
152
|
-
ok(`llms.txt ${code}: не пишем «у вас нет файла»`, !a5.findings.some((f) => f.id === 'llms-missing'));
|
|
153
|
-
ok(`llms.txt ${code}: сказано, что не отдали`, /не дал этой проверке прочитать llms\.txt/.test(a5.findings[0].text));
|
|
154
|
-
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));
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ---- две неизмеренные области сразу: знаменатель считается по оставшимся трём
|
|
160
|
-
{
|
|
161
|
-
const { s: s6, url: url6 } = await serve({ robots: null, llmsStatus: 403 });
|
|
162
|
-
let v6;
|
|
163
|
-
try { v6 = await checkVisibility(url6, { budgetMs: 8500, samplePages: 3, lang: 'ru' }); } finally { s6.close(); }
|
|
164
|
-
const un = v6.areas.filter((a) => a.measured === false);
|
|
165
|
-
is('неизмеренных областей две', un.length, 2);
|
|
166
|
-
const meas = v6.areas.filter((a) => a.measured !== false);
|
|
167
|
-
is('знаменатель 60', meas.reduce((t, a) => t + a.max, 0), 60);
|
|
168
|
-
is('балл по оставшимся', v6.score, Math.round((meas.reduce((t, a) => t + a.score, 0) / 60) * 100));
|
|
169
|
-
ok('ни одна из них не получила ноль', un.every((a) => a.score === null));
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
173
|
-
console.log('\nнепрочитанное не считается ни в плюс, ни в минус, а отсутствующее это ответ');
|