@operstack/audit 0.36.2 → 0.38.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.
Files changed (50) hide show
  1. package/README.md +10 -0
  2. package/bin/audit.mjs +2 -2
  3. package/package.json +5 -3
  4. package/src/client-eye.mjs +389 -0
  5. package/src/collect.mjs +171 -85
  6. package/src/fix.mjs +124 -5
  7. package/src/foundation.mjs +2 -1
  8. package/src/i18n.mjs +50 -25
  9. package/src/index.mjs +2 -1
  10. package/src/narrative.mjs +56 -25
  11. package/src/niche.mjs +13 -12
  12. package/src/pagekind.mjs +18 -1
  13. package/src/presentation.mjs +3 -2
  14. package/src/prompts.mjs +163 -42
  15. package/src/render.mjs +61 -30
  16. package/src/rendered.mjs +6 -2
  17. package/src/tasks-pdf.mjs +10 -5
  18. package/src/verify.mjs +26 -13
  19. package/src/visibility.mjs +137 -45
  20. package/src/vy.mjs +13 -0
  21. package/src/test-agency-ids.mjs +0 -48
  22. package/src/test-agency.mjs +0 -158
  23. package/src/test-agent-surface.mjs +0 -46
  24. package/src/test-answer-first.mjs +0 -27
  25. package/src/test-blocked.mjs +0 -100
  26. package/src/test-crawl-links.mjs +0 -87
  27. package/src/test-demand.mjs +0 -243
  28. package/src/test-etalon.mjs +0 -42
  29. package/src/test-fix.mjs +0 -115
  30. package/src/test-flaky-files.mjs +0 -81
  31. package/src/test-foundation.mjs +0 -126
  32. package/src/test-handover.mjs +0 -148
  33. package/src/test-i18n.mjs +0 -68
  34. package/src/test-narrative.mjs +0 -117
  35. package/src/test-overall.mjs +0 -236
  36. package/src/test-planted.mjs +0 -114
  37. package/src/test-presentation.mjs +0 -67
  38. package/src/test-prompts.mjs +0 -142
  39. package/src/test-prospect-count.mjs +0 -12
  40. package/src/test-redirect-host.mjs +0 -63
  41. package/src/test-rendered.mjs +0 -136
  42. package/src/test-report-wording.mjs +0 -35
  43. package/src/test-ru-report.mjs +0 -95
  44. package/src/test-ru-sources.mjs +0 -41
  45. package/src/test-sample-fairness.mjs +0 -50
  46. package/src/test-sample.mjs +0 -91
  47. package/src/test-scores.mjs +0 -75
  48. package/src/test-shopify-store.mjs +0 -159
  49. package/src/test-sitemap.mjs +0 -91
  50. package/src/test-unmeasured.mjs +0 -173
@@ -1,142 +0,0 @@
1
- #!/usr/bin/env node
2
- /** Задания для ИИ-агента: полнота, язык, запрет на выдумки. */
3
- import { readFileSync } from 'node:fs';
4
- import { agentPrompt, agentPrompts, renderAgentPrompts, firstFixParts, FIRST_FIX_IDS } from './prompts.mjs';
5
- import { FIX_ACTIONS } from './fix.mjs';
6
- import { localiseChecks } from './i18n.mjs';
7
-
8
- let bad = 0;
9
- const is = (n, a, b) => { const ok = JSON.stringify(a) === JSON.stringify(b); if (!ok) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
10
- const ok = (n, c) => is(n, Boolean(c), true);
11
-
12
- const c = (id, status, label, value) => ({ id, status, label, value, group: 'technical' });
13
- const audit = (checks, lang = 'en') => ({ meta: { host: 'x.ru', lang }, checks });
14
-
15
- const one = agentPrompt(c('alt', 'bad', 'Подписи к картинкам', '3 из 10 картинок с подписью'), { lang: 'ru' });
16
- ok('в задании есть точка отсчёта', one.includes('**Сейчас:** 3 из 10'));
17
- ok('в задании есть само действие', one.includes('**Задача:**'));
18
- ok('в задании есть признак готовности', one.includes('**Как проверить:**'));
19
- ok('в задании есть запрет выдумывать', one.includes('не выдумывай факты о бизнесе'));
20
-
21
- // Даже там, где своего текста нет, все три части на месте
22
- const generic = agentPrompt(c('xmlrpc', 'warn', 'xmlrpc.php', 'открыт'), { lang: 'ru' });
23
- ok('общее задание тоже с проверкой', generic.includes('**Как проверить:**'));
24
- ok('и со своим действием', generic.includes('Закрой точку xmlrpc.php'));
25
-
26
- // Порядок: сначала провалы, потом предупреждения
27
- const list = agentPrompts(audit([c('og', 'warn', 'OG', 'нет'), c('robots', 'bad', 'robots', 'закрыт')]), { lang: 'ru' });
28
- is('первым идёт провал, а не предупреждение', list[0].id, 'robots');
29
- is('заданий столько же, сколько открытых проверок', list.length, 2);
30
- is('пройденные проверки заданий не порождают', agentPrompts(audit([c('og', 'ok', 'OG', 'есть')])).length, 0);
31
- is('лимит режет список', agentPrompts(audit([c('og', 'warn', 'OG', 'нет'), c('robots', 'bad', 'r', 'з')]), { limit: 1 }).length, 1);
32
-
33
- // Язык: русский файл по английскому аудиту не должен содержать английских проверок
34
- const ruFile = renderAgentPrompts(audit([c('alt', 'bad', 'Image alt text', '3 of 10 images carry alt text (30%) across 5 sampled page(s)')], 'en'), { lang: 'ru' });
35
- ok('название проверки переведено', ruFile.includes('Подписи к картинкам'));
36
- ok('и значение тоже', !ruFile.includes('images carry alt text'));
37
- const enFile = renderAgentPrompts(audit([c('alt', 'bad', 'Image alt text', '3 of 10 images')], 'en'), { lang: 'en' });
38
- ok('английский файл без кириллицы', !/[А-Яа-яЁё]/.test(enFile));
39
-
40
- // Задания не обещают того, чего мы не знаем
41
- const contact = agentPrompt(c('conv-messenger', 'bad', 'Мессенджер', 'нет'), { lang: 'ru' });
42
- ok('не выбирает мессенджер за клиента', /не знаешь, какой мессенджер|спроси владельца/i.test(contact));
43
- const src = agentPrompt(c('sources', 'bad', 'Источники', '2 из 20'), { lang: 'ru' });
44
- ok('запрещает ставить ссылку наугад', /не ставь ссылку наугад|неверный источник хуже/i.test(src));
45
- const af = agentPrompt(c('answer-first', 'bad', 'Ответ в начале', '2 из 20'), { lang: 'ru' });
46
- ok('запрещает придумывать цифру', /не придумывай её|спроси владельца/.test(af));
47
-
48
- // Покрытие: у каждой проверки, которую мы беремся чинить, задание осмысленное
49
- const mechanical = Object.entries(FIX_ACTIONS).filter(([, r]) => r.scope === 'always' || r.scope === 'files');
50
- const empty = mechanical.filter(([id]) => {
51
- const p = agentPrompt(c(id, 'bad', id, 'значение'), { lang: 'ru' });
52
- return !p.includes('**Задача:**') || p.split('**Задача:**')[1].trim().length < 20;
53
- }).map(([id]) => id);
54
- is('у каждой чинимой проверки есть внятное задание', empty, []);
55
-
56
- // Ноль, который означает отсутствие, пишется словами, а не цифрой. 14.09.2026 в доставленном
57
- // письме стояло «Сейчас: 0 знаков», и человек не понимал, что описания у страницы просто нет.
58
- const zeros = [
59
- ['description', 'no meta description on the page', /описания у страницы нет/],
60
- ['title', 'no title on the page', /заголовка у страницы нет/],
61
- ['h1', 'no H1 on the page', /заголовка H1 на странице нет/],
62
- // У robots ноль правил это настоящее измерение, а не отсутствие: словами пишется только
63
- // пропавшая строка Sitemap, поэтому в образце правил три.
64
- ['robots', '3 disallow rule(s) in 2 agent block(s), no sitemap line', /строки с картой сайта нет/],
65
- ];
66
- const nowLine = (text) => (text.split('\n').find((l) => l.startsWith('**Сейчас:**')) || '');
67
- for (const [id, value, expect] of zeros) {
68
- const localised = renderAgentPrompts(audit([c(id, 'bad', id, value)], 'en'), { lang: 'ru' });
69
- const now = nowLine(localised);
70
- ok(`${id}: по-английски ноль не выводится цифрой`, !/(^|\s)0 (chars|H1|sitemap)/.test(value));
71
- ok(`${id}: по-русски отсутствие названо словами`, expect.test(now));
72
- // Проверяем именно строку «Сейчас»: в тексте задачи числа законны («в 120-160 знаков»).
73
- ok(`${id}: в строке «Сейчас» нет обрывка с нулём`, !/(^|\s)0\s/.test(now));
74
- }
75
-
76
- // Шапка файла объясняет, как этим пользоваться
77
- const head = renderAgentPrompts(audit([c('robots', 'bad', 'robots', 'закрыт')]), { lang: 'ru' });
78
- // Проверяем смысл, а не точную формулировку: шапку переписывали под покупателя без терминала.
79
- ok('шапка объясняет, что копировать задачу целиком', /задачу целиком/i.test(head) && /Сейчас/.test(head) && /Как проверить/.test(head));
80
- ok('и предупреждает делать по одной', /по одной за раз/i.test(head));
81
- ok('и обещает, что код знать не нужно', /кода знать не нужно/i.test(head));
82
- ok('в файле нет пустых сдвоенных строк', !/\n\n\n/.test(head));
83
-
84
- // ---- первая правка на бесплатной странице тремя частями.
85
- // Список id берётся из исходника бесплатной проверки, а не из головы: новая находка без
86
- // сопоставления уронит этот тест, а не оставит покупателя без задачи молча.
87
- {
88
- const src = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');
89
- const ids = [...new Set([...src.matchAll(/id: '([a-z0-9-]+)', level: (?!'na')/g)].map((m) => m[1]))];
90
- ok(`находок бесплатной проверки в исходнике больше десяти (${ids.length})`, ids.length > 10);
91
- const missing = [];
92
- for (const id of ids) for (const lang of ['ru', 'en']) {
93
- const p = firstFixParts({ id, area: 'x', text: 'что-то измерено' }, { lang });
94
- if (!p || !p.now || p.task.length < 30 || p.verify.length < 20 || /null|undefined/.test(JSON.stringify(p))) missing.push(`${id}/${lang}`);
95
- else if (lang === 'en' && /[А-Яа-яЁё]/.test(p.task + p.verify + p.rule + Object.values(p.labels).join(''))) missing.push(`${id}/en: кириллица`);
96
- }
97
- is('у каждой находки бесплатной проверки есть три части на обоих языках', missing, []);
98
- is('и в списке экспорта нет лишних id, которых проверка не выдаёт', FIRST_FIX_IDS.filter((id) => !ids.includes(id)), []);
99
- is('неизвестная находка даёт null, а не чужое указание', firstFixParts({ id: 'что-то-новое', text: 'x' }, { lang: 'ru' }), null);
100
- is('находка без текста даёт null', firstFixParts({ id: 'llms-missing', text: '' }, { lang: 'ru' }), null);
101
- is('пустой вход даёт null', firstFixParts(null), null);
102
- const p = firstFixParts({ id: 'robots-fetchers-blocked', area: 'Пускает ли роботов ИИ', text: 'Закрыты поисковые роботы ответных систем: OAI-SearchBot.' }, { lang: 'ru' });
103
- is('«сейчас» это сам текст находки', p.now, 'Закрыты поисковые роботы ответных систем: OAI-SearchBot.');
104
- ok('задача про открытие поисковых роботов', /Открой в robots\.txt/.test(p.task));
105
- ok('подписи на русском', p.labels.now === 'Сейчас' && p.labels.task === 'Задача' && p.labels.verify === 'Как проверить');
106
- ok('правило про выдумки на месте', /не выдумывай факты/.test(p.rule));
107
- const e = firstFixParts({ id: 'llms-missing', text: 'No llms.txt.' }, { lang: 'en' });
108
- ok('английские подписи', e.labels.now === 'Now' && e.labels.verify === 'How to check');
109
- ok('незнакомый язык падает в английский', firstFixParts({ id: 'llms-missing', text: 'x' }, { lang: 'de' }).labels.now === 'Now');
110
- }
111
-
112
- /*
113
- * У каждой проверки, которая может выйти находкой, своё задание. 01.10.2026 в первых настоящих
114
- * выдачах (dial93.ru, bristolpropertycentre.co.uk) «Задача» у части проверок просто повторяла
115
- * название, а «Как проверить» была общей фразой «прогони проверку заново». Список проверок берём
116
- * прямо из collect.mjs, чтобы новая проверка без задания роняла этот тест.
117
- */
118
- {
119
- const src = readFileSync(new URL('./collect.mjs', import.meta.url), 'utf8');
120
- const rows = [...src.matchAll(/row\(\s*['`]([^'`]+)['`]\s*,\s*'([a-z]+)'\s*,\s*['`"]([^'`"]*)['`"]/g)]
121
- .map((m) => ({ id: m[1].replace('${e.id}', 'chatgpt'), label: m[3].replace('${e.label}', 'ChatGPT') }));
122
- rows.push({ id: 'js-content', label: 'Text visible without running JavaScript' });
123
- // Эти строки выходят только со статусом na (справка или осознанный выбор), находкой не бывают.
124
- const NEVER_OPEN = new Set(['cms', 'robots-ai', 'howto-schema', 'product-schema', 'hreflang', 'links-profile']);
125
- ok('список проверок из collect.mjs прочитан', rows.length > 40);
126
- const field = (text, name) => (text.match(new RegExp(`\\*\\*${name}:\\*\\* (.*)`)) || [])[1] || '';
127
- const empty = [];
128
- for (const r of rows.filter((x) => !NEVER_OPEN.has(x.id))) {
129
- for (const lang of ['ru', 'en']) {
130
- const [chk] = lang === 'ru' ? localiseChecks([c(r.id, 'warn', r.label, 'x')], 'ru') : [c(r.id, 'warn', r.label, 'x')];
131
- const t = agentPrompt(chk, { lang });
132
- const task = field(t, lang === 'ru' ? 'Задача' : 'Task').trim();
133
- const verify = field(t, lang === 'ru' ? 'Как проверить' : 'How to check').trim();
134
- const generic = lang === 'ru' ? /^Прогони проверку сайта заново/ : /^Run the site check again/;
135
- if (!task || task === chk.label || task === r.label || task.length < 60 || !verify || generic.test(verify)) empty.push(`${r.id}/${lang}`);
136
- }
137
- }
138
- is('у каждой проверки своё задание и своя проверка, не название', empty, []);
139
- }
140
-
141
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
142
- console.log('\nвсе тесты заданий прошли');
@@ -1,12 +0,0 @@
1
- // Фраза про короткий ответ зависит от числа страниц (задание 26.09.2026): «Not one page» только при нуле.
2
- import assert from 'node:assert/strict';
3
- import { prospectRow } from './prospect.mjs';
4
- const audit = (value, status) => ({ meta: { host: 'a.test' }, scores: {}, checks: [{ id: 'answer-first', status, label: 'Answer-first opening paragraph', value }] });
5
- const warn = prospectRow(audit('5 of 8 sampled page(s) open with 20 to 90 words carrying a figure, right after the H1', 'warn'));
6
- assert.equal(warn.say, 'Only 5 of your 8 checked pages open with a short direct answer, and that opening is exactly what gets quoted.');
7
- const bad = prospectRow(audit('0 of 8 sampled page(s) open with 20 to 90 words carrying a figure, right after the H1', 'bad'));
8
- assert.match(bad.say, /^Not one page opens/);
9
- const ru = prospectRow(audit('5 of 8 sampled page(s) open with 20 to 90 words carrying a figure, right after the H1', 'warn'), { lang: 'ru' });
10
- assert.equal(ru.say, 'Короткий прямой ответ в первом абзаце есть только на 5 из 8 проверенных страниц, а цитируют именно его.');
11
- assert.match(prospectRow(audit('', 'bad')).say, /^Not one page opens/, 'без числа прежняя фраза');
12
- console.log('test-prospect-count: ok');
@@ -1,63 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Голый домен, который отвечает 301 на www, должен читаться так же, как сам www.
4
- *
5
- * 30.09.2026 bristolpropertycentre.co.uk получил в отчёте «no sitemap found at the usual paths» и
6
- * «1 pages sampled»: robots.txt и карта сайта брались с введённого адреса без перехода по 301, а
7
- * выборка в бесплатной проверке отсеивала страницы карты, потому что они на www. Тот же сайт,
8
- * введённый с www, давал сто страниц. Здесь это держится на живом сервере: lvh.me и www.lvh.me оба
9
- * указывают на 127.0.0.1, и сервер по заголовку Host отправляет голый домен на www.
10
- */
11
- import { createServer } from 'node:http';
12
- import { lookup } from 'node:dns/promises';
13
- import { collect } from './collect.mjs';
14
- import { checkVisibility } from './visibility.mjs';
15
-
16
- let bad = 0;
17
- const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
18
-
19
- let dnsOk = true;
20
- try { dnsOk = (await lookup('lvh.me')).address === '127.0.0.1' && (await lookup('www.lvh.me')).address === '127.0.0.1'; } catch { dnsOk = false; }
21
- if (!dnsOk) { console.log('ok пропуск: lvh.me не указывает на 127.0.0.1 (нет сети)'); process.exit(0); }
22
-
23
- const PAGES = ['/', '/buy/', '/rent/', '/sell/', '/about/', '/contact/'];
24
- const page = (title) => `<!doctype html><html lang="en"><head><title>${title}</title>
25
- <meta name="description" content="A page description long enough not to count as empty for the check.">
26
- </head><body><main><h1>${title}</h1>${'<p>Plain paragraph about the property, the area and the price.</p>'.repeat(10)}</main></body></html>`;
27
-
28
- const server = createServer((req, res) => {
29
- const host = (req.headers.host || '').toLowerCase();
30
- const www = `http://www.${host.replace(/^www\./, '')}`;
31
- if (!host.startsWith('www.')) { res.writeHead(301, { location: `${www}${req.url}` }); return res.end(); }
32
- const send = (code, type, body) => { res.writeHead(code, { 'content-type': type }); res.end(body); };
33
- if (req.url === '/robots.txt') return send(200, 'text/plain', `User-agent: *\nAllow: /\nSitemap: ${www}/sitemap.xml\n`);
34
- if (req.url === '/llms.txt') return send(200, 'text/plain', `# Site\n\n- [Buy](${www}/buy/)\n`);
35
- if (req.url === '/sitemap.xml') return send(200, 'application/xml', `<?xml version="1.0"?><urlset>${PAGES.map((p) => `<url><loc>${www}${p}</loc></url>`).join('')}</urlset>`);
36
- if (PAGES.includes(req.url)) return send(200, 'text/html; charset=utf-8', page(`Page ${req.url}`));
37
- return send(404, 'text/plain', 'no');
38
- });
39
- await new Promise((r) => server.listen(0, '127.0.0.1', r));
40
- const port = server.address().port;
41
- const apex = `http://lvh.me:${port}/`;
42
-
43
- try {
44
- const a = await collect(apex, { pages: 10, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
45
- const check = (id) => a.checks.find((c) => c.id === id) || {};
46
- ok('конечный адрес сайта на www', a.meta.site.startsWith(`http://www.lvh.me:${port}`));
47
- ok(`robots.txt прочитан через 301 (${check('robots').status}: ${check('robots').value})`, check('robots').status === 'ok');
48
- ok(`карта сайта найдена (${check('sitemap').value})`, check('sitemap').status === 'ok' && /6 URL/.test(check('sitemap').value));
49
- ok('карта не считается чужим хостом', !a.checks.some((c) => c.id === 'sitemap-foreign'));
50
- ok(`llms.txt прочитан через 301 (${check('llms').value})`, check('llms').status === 'ok');
51
- ok('в обзоре шесть страниц в карте', a.overview.rows.find((r) => r[0] === 'Pages in sitemap')?.[1] === '6');
52
- const sampled = Number((a.sources[0].detail.match(/homepage and (\d+) sampled/) || [])[1]);
53
- ok(`выборка не одна главная (${sampled} страниц)`, sampled >= 5);
54
- ok(`www-канонизация названа от голого домена к www (${check('www').value})`, /^lvh\.me:\d+ redirects to www\.lvh\.me/.test(check('www').value));
55
-
56
- const v = await checkVisibility(apex, { lang: 'en', samplePages: 5, budgetMs: 8000 });
57
- ok('бесплатная проверка прочитала сайт', v.ok);
58
- ok(`бесплатная проверка нашла карту (${v.sitemap?.count})`, v.sitemap?.found && v.sitemap.count === 6);
59
- ok(`в выборке не только главная (${v.sample?.length} страниц)`, (v.sample?.length || 0) >= 3);
60
- } finally { server.close(); }
61
-
62
- if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
63
- console.log('\nвсё сходится');
@@ -1,136 +0,0 @@
1
- #!/usr/bin/env node
2
- /** Слепота к скриптам: считаем честно, не измерив, так и говорим. */
3
- import { chmodSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { compareReadings, jsBlindnessCheck, isBrowserErrorPage, renderedDom, removeProfile } from './rendered.mjs';
7
-
8
- let bad = 0;
9
- const is = (n, a, b) => { const ok = JSON.stringify(a) === JSON.stringify(b); if (!ok) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
10
- const ok = (n, c) => is(n, Boolean(c), true);
11
-
12
- // Профиль Chrome: ENOTEMPTY на macOS ронял весь сбор (brainmd.com, 12.10.2026).
13
- {
14
- let opts = null;
15
- is('удаление профиля с повторами', removeProfile('/tmp/x', (d, o) => { opts = o; }), true);
16
- ok('повторы включены', opts && opts.maxRetries >= 3 && opts.recursive);
17
- const fail = () => { const e = new Error('ENOTEMPTY: directory not empty'); e.code = 'ENOTEMPTY'; throw e; };
18
- is('ENOTEMPTY не роняет сбор', removeProfile('/tmp/x', fail), false);
19
- }
20
-
21
- const text = (n) => '<p>' + 'слово '.repeat(n) + '</p>';
22
- const body = (inner) => `<html><body>${inner}</body></html>`;
23
-
24
- is('одинаковые чтения дают ноль скрытого', compareReadings(body(text(300)), body(text(300))).hiddenShare, 0);
25
- is('и вердикт «в порядке»', compareReadings(body(text(300)), body(text(300))).verdict, 'ok');
26
-
27
- const empty = compareReadings(body('<div id="root"></div>'), body(text(300)));
28
- is('пустой каркас против полной страницы это сто процентов', empty.hiddenShare, 100);
29
- is('и это приговор', empty.verdict, 'bad');
30
-
31
- const half = compareReadings(body(text(100)), body(text(200)));
32
- is('половина текста за скриптами считается верно', half.hiddenShare, 50);
33
- is('и это тоже приговор', half.verdict, 'bad');
34
-
35
- is('пять процентов за скриптами это ещё в порядке', compareReadings(body(text(285)), body(text(300))).verdict, 'ok');
36
- const tenth = compareReadings(body(text(270)), body(text(300)));
37
- is('десятая часть текста за скриптами это уже предупреждение', tenth.verdict, 'warn');
38
- is('и доля посчитана', tenth.hiddenShare, 10);
39
-
40
- // Меню и подвал не считаются: их и так никто не цитирует
41
- const chrome = compareReadings(body('<nav>меню меню меню</nav>' + text(100)), body('<nav>меню меню меню другое</nav>' + text(100)));
42
- is('разница только в меню не считается скрытым текстом', chrome.hiddenShare, 0);
43
-
44
- // Браузер увидел меньше: это его беда, а не отрицательная доля
45
- is('отрицательной доли не бывает', compareReadings(body(text(300)), body(text(100))).hiddenShare, 0);
46
-
47
- // Строка проверки
48
- const good = jsBlindnessCheck([{ url: 'https://x.ru/', raw: 300, rendered: 300, hiddenShare: 0, verdict: 'ok' }], 'ru');
49
- is('чистый сайт получает «ok»', good.status, 'ok');
50
- ok('и понятный текст', /читаются целиком/.test(good.comment));
51
-
52
- const worst = jsBlindnessCheck([
53
- { url: 'https://x.ru/a', raw: 300, rendered: 300, hiddenShare: 0, verdict: 'ok' },
54
- { url: 'https://x.ru/b', raw: 10, rendered: 400, hiddenShare: 98, verdict: 'bad' },
55
- ], 'ru');
56
- is('одна плохая страница делает проверку проваленной', worst.status, 'bad');
57
- ok('в тексте названа худшая страница', worst.value.includes('https://x.ru/b'));
58
- ok('и сказано, сколько слов видно без скриптов', /10 слов без скриптов против 400/.test(worst.value));
59
- ok('приговор говорит про роботов ИИ', /достают страницу в ответ ИИ/.test(worst.comment));
60
-
61
- const mild = jsBlindnessCheck([{ url: 'https://x.ru/', raw: 86, rendered: 100, hiddenShare: 14, verdict: 'warn' }], 'ru');
62
- ok('на четырнадцати процентах не пишем «страница почти пустая»', !/почти пустая/.test(mild.comment));
63
- ok('а пишем, что часть текста не доходит', /не доходит/.test(mild.comment));
64
-
65
- const none = jsBlindnessCheck([], 'ru');
66
- is('без браузера статус «не измеряли»', none.status, 'na');
67
- ok('и сказано почему, без слов про «машину»', /не измеряли/.test(none.value) && !/машин/.test(none.value));
68
-
69
- const en = jsBlindnessCheck([{ url: 'https://x.com/', raw: 10, rendered: 400, hiddenShare: 98, verdict: 'bad' }], 'en');
70
- ok('английская версия без кириллицы', !/[А-Яа-яЁё]/.test(JSON.stringify(en)));
71
-
72
- // ---- страница ошибки браузера это не сайт покупателя
73
- //
74
- // 14.09.2026 браузер не смог открыть сайт и напечатал свою страницу «не удалось подключиться»:
75
- // сто восемьдесят килобайт интерфейса Chrome. Приняв её за «как выглядит со скриптами», мы бы
76
- // написали покупателю в платном отчёте, что его сайт не читается без скриптов. Выдуманная
77
- // находка по чужому сайту это худшее, что может быть в отчёте за деньги.
78
- {
79
- const errorPage = '<!DOCTYPE html><html dir="ltr" lang="ru"><head><style>/* Copyright 2017 The Chromium Authors */</style>'
80
- + '<script>function neterror(){}</script></head><body><div id="main-frame-error"><span>Не удалось открыть страницу</span></div></body></html>';
81
- ok('страница ошибки браузера распознана', isBrowserErrorPage(errorPage));
82
- ok('настоящая страница не принята за ошибку', !isBrowserErrorPage(body(text(300))));
83
- ok('пустой ответ не принят за ошибку', !isBrowserErrorPage(''));
84
- // Одного совпадения мало: слово neterror может встретиться в статье про ошибки браузера.
85
- ok('одного слова недостаточно', !isBrowserErrorPage('<html><body><p>Статья про neterror и как его читать</p></body></html>'));
86
- }
87
-
88
- // ---- браузер обязан отпускать, даже если сам не уходит
89
- //
90
- // Chrome печатает разметку и может не завершиться: его вспомогательные процессы держат канал
91
- // вывода открытым. Отчёт по habr.com из-за этого стоял сорок минут при нулевой загрузке.
92
- {
93
- const dir = mkdtempSync(join(tmpdir(), 'operstack-fake-chrome-'));
94
- const fake = (script) => {
95
- const f = join(dir, `chrome-${Math.random().toString(36).slice(2)}.sh`);
96
- writeFileSync(f, `#!/bin/sh\n${script}\n`);
97
- chmodSync(f, 0o755);
98
- return f;
99
- };
100
-
101
- // Напечатал страницу целиком и завис. Ждать его незачем: всё уже у нас.
102
- const printsThenHangs = fake(`printf '%s' '<html><body><p>текст страницы</p></body></html>'\nsleep 60`);
103
- const t0 = Date.now();
104
- const dom = await renderedDom('https://example.com/', { chrome: printsThenHangs, timeoutMs: 10000 });
105
- const took = Date.now() - t0;
106
- ok('разметка получена, хотя браузер не вышел', typeof dom === 'string' && dom.includes('текст страницы'));
107
- ok(`и получена сразу, а не по сроку (${(took / 1000).toFixed(1)} с)`, took < 5000);
108
-
109
- // Ничего не печатает и не уходит. Здесь обязан сработать срок, и вернуться «не измеряли».
110
- const silentHang = fake('sleep 60');
111
- const t1 = Date.now();
112
- const none = await renderedDom('https://example.com/', { chrome: silentHang, timeoutMs: 2500 });
113
- const waited = Date.now() - t1;
114
- is('молчащий браузер это «не измеряли», а не выдумка', none, null);
115
- ok(`и ожидание кончилось в срок (${(waited / 1000).toFixed(1)} с)`, waited >= 2000 && waited < 9000);
116
-
117
- // Напечатал свою страницу ошибки: не мерим.
118
- const printsError = fake(`printf '%s' '<html><head><style>/* Copyright The Chromium Authors */</style></head><body><div id="main-frame-error">нет связи</div></body></html>'`);
119
- is('страница ошибки не идёт в измерение', await renderedDom('https://example.com/', { chrome: printsError, timeoutMs: 8000 }), null);
120
-
121
- rmSync(dir, { recursive: true, force: true });
122
- }
123
-
124
- // cimes.ru 14.10.2026: меню, достроенное скриптом вне <nav>, и баннер cookie не «скрытый текст».
125
- {
126
- const body = '<main><p>' + 'Создание сайтов под ключ для бизнеса '.repeat(40) + '</p></main>';
127
- const raw = `<html><body><header><div class="logo">Cimes</div></header>${body}</body></html>`;
128
- const ren = raw.replace('</header>', '<div class="header-menu dropdown">' + 'Услуги Готовые решения Аспро Максимум Подробнее Демо '.repeat(30) + '</div></header>')
129
- .replace('</main>', '</main><div class="cookie-settings"><p>' + 'Управление cookie файлами принять все '.repeat(20) + '</p></div>');
130
- is('меню и баннер cookie не скрытый текст', compareReadings(raw, ren).hiddenShare, 0);
131
- const spa = `<html><body><div id="root"></div></body></html>`;
132
- ok('настоящая страница на скриптах по-прежнему видна', compareReadings(spa, spa.replace('<div id="root"></div>', body)).verdict === 'bad');
133
- }
134
-
135
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
136
- console.log('\nвсе тесты слепоты к скриптам прошли');
@@ -1,35 +0,0 @@
1
- // 08.10.2026: отчёт за 800 ₽ не называется аудитом; задание про форму не противоречит строке «Сейчас».
2
- import { readFileSync } from 'node:fs';
3
- import { labelsFor, toHtml } from './render.mjs';
4
- import { agentPrompt } from './prompts.mjs';
5
-
6
- let failed = 0;
7
- const ok = (name, cond) => { console.log(`${cond ? 'ok ' : 'FAIL'} ${name}`); if (!cond) failed++; };
8
-
9
- for (const tier of ['9', '29', 'watch', 'free']) {
10
- const ru = labelsFor('ru', tier); const en = labelsFor('en', tier);
11
- ok(`ступень ${tier}: русская обложка без слова «аудит»`, !/аудит/i.test(ru.coverTitle + ru.eyebrowAudit + ru.runningHead + ru.auditType + ru.docTitle));
12
- ok(`ступень ${tier}: английская обложка без слова audit`, !/audit/i.test(en.coverTitle + en.eyebrowAudit + en.runningHead + en.auditType + en.docTitle));
13
- ok(`ступень ${tier}: сноски без слова «аудит»`, !/аудит/i.test(ru.scorecardFoot + ru.sourcesFoot) && !/audit/i.test(en.scorecardFoot + en.sourcesFoot));
14
- }
15
- for (const tier of ['audit', '', undefined]) {
16
- ok(`ступень «${tier}»: аудит остаётся аудитом`, /Аудит/.test(labelsFor('ru', tier).coverTitle) && /audit/.test(labelsFor('en', tier).coverTitle));
17
- }
18
-
19
- const sample = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url)));
20
- const html9 = toHtml({ ...sample, meta: { ...sample.meta, lang: 'ru', tier: '9' } });
21
- ok('PDF за 800 ₽: русский title и lang', /<html lang="ru">/.test(html9) && /: отчёт SEO, AEO и GEO<\/title>/.test(html9));
22
- ok('PDF за 800 ₽: в шапках страниц «Отчёт», не «Аудит»', /Отчёт SEO, AEO и GEO ·/.test(html9) && !/Аудит SEO, AEO и GEO ·/.test(html9));
23
- const htmlAudit = toHtml({ ...sample, meta: { ...sample.meta, lang: 'ru', tier: 'audit' } });
24
- ok('аудит за 12 500 ₽: обложка прежняя', /Аудит сайта/.test(htmlAudit));
25
-
26
- const noForm = agentPrompt({ id: 'conv-form-depth', label: 'На какой глубине лежит форма', value: 'на проверенных страницах формы нет', status: 'bad' }, { lang: 'ru' });
27
- ok('формы нет: заголовок задачи «Форма заявки»', noForm.startsWith('### Форма заявки'));
28
- ok('формы нет: задание не просит поднять форму', /нет формы заявки/.test(noForm) && !/Подними форму/.test(noForm));
29
- const noFormEn = agentPrompt({ id: 'conv-form-depth', label: 'How far down the form sits', value: 'no form on the sampled pages', status: 'bad' }, { lang: 'en' });
30
- ok('no form: English task asks first', /no enquiry form/.test(noFormEn) && !/Move the enquiry form higher/.test(noFormEn));
31
- const deep = agentPrompt({ id: 'conv-form-depth', label: 'На какой глубине лежит форма', value: 'медиана 85% вниз по странице, измерено на 3 страницах', status: 'bad' }, { lang: 'ru' });
32
- ok('форма есть, но низко: прежнее задание', /Подними форму/.test(deep));
33
-
34
- if (failed) { console.log(`\n${failed} не сошлось`); process.exit(1); }
35
- console.log('\nвсё сходится');
@@ -1,95 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Русский сайт в русском отчёте: правовые страницы находятся по русским адресам, и в audit.json
4
- * нет английских подписей.
5
- *
6
- * 30.09.2026 образец аудита агентства из Екатеринбурга (collect --lang ru) дал красное «ни политики
7
- * конфиденциальности, ни условий на сайте нет» при ссылке href="politika-konfidenczialnosti/", а в
8
- * плане работ, обзоре, источниках и ограничениях стояли «Week 1», «Fix: Privacy and terms»,
9
- * «The site itself» и «Analytics: no traffic or behaviour data».
10
- */
11
- import { createServer } from 'node:http';
12
- import { mkdtemp, readFile } from 'node:fs/promises';
13
- import { tmpdir } from 'node:os';
14
- import path from 'node:path';
15
- import { collect, policyKinds } from './collect.mjs';
16
- import { draftNarrative } from './narrative.mjs';
17
- import { render } from './render.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
- // ---- какие правовые страницы стоят за ссылкой
24
- is('русская политика без косой черты в начале', policyKinds('politika-konfidenczialnosti/'), ['privacy']);
25
- is('русская политика с косой чертой', policyKinds('/politika-konfidencialnosti/'), ['privacy']);
26
- is('конфиденциальность отдельным словом', policyKinds('/konfidencialnost/'), ['privacy']);
27
- is('согласие на обработку данных', policyKinds('/soglasie-na-obrabotku-personalnyh-dannyh/'), ['consent']);
28
- is('пользовательское соглашение это условия, а не согласие', policyKinds('/polzovatelskoe-soglashenie/'), ['terms']);
29
- is('условия и оферта', [policyKinds('usloviya-ispolzovaniya/'), policyKinds('/docs/oferta.pdf')], [['terms'], ['terms']]);
30
- is('кириллица в адресе, в том числе закодированная', [policyKinds('/политика-конфиденциальности/'), policyKinds('/%D0%BE%D1%84%D0%B5%D1%80%D1%82%D0%B0/')], [['privacy'], ['terms']]);
31
- is('полный адрес', policyKinds('https://dial93.ru/politika-konfidenczialnosti/'), ['privacy']);
32
- is('английские адреса как раньше', [policyKinds('/privacy-policy'), policyKinds('/en/terms-of-use/'), policyKinds('/cookies'), policyKinds('/legal/'), policyKinds('/disclaimer')], [['privacy'], ['terms'], ['cookie'], ['legal'], ['disclaimer']]);
33
- is('испанские акции не условия', policyKinds('/ofertas/'), []);
34
- is('почта, телефон и якорь не страницы', [policyKinds('mailto:privacy@x.ru'), policyKinds('tel:+7'), policyKinds('#privacy')], [[], [], []]);
35
- is('обычные страницы не правовые', [policyKinds('/kvartiry/'), policyKinds('/o-kompanii/'), policyKinds('/politics-news/')], [[], [], []]);
36
-
37
- // ---- живой русский сайт, отчёт по-русски
38
- const PAGES = ['/', '/kvartiry/', '/doma/', '/kontakty/'];
39
- const page = (p) => `<!doctype html><html lang="ru"><head><title>Агентство недвижимости ${p}</title>
40
- <meta name="description" content="Квартиры и дома в Екатеринбурге: подбор, проверка и сопровождение сделки под ключ.">
41
- </head><body><main><h1>Недвижимость ${p}</h1>${'<p>Квартира в новом доме, 54 квадратных метра, цена 7,2 млн рублей, сдача в 2027 году.</p>'.repeat(12)}</main>
42
- <footer><a href="politika-konfidenczialnosti/">Политика конфиденциальности</a> <a href="/soglasie-na-obrabotku/">Согласие на обработку</a></footer></body></html>`;
43
- const server = createServer((req, res) => {
44
- const send = (code, type, body) => { res.writeHead(code, { 'content-type': type }); res.end(body); };
45
- const www = `http://${req.headers.host}`;
46
- if (req.url === '/robots.txt') return send(200, 'text/plain', `User-agent: *\nAllow: /\nSitemap: ${www}/sitemap.xml\n`);
47
- if (req.url === '/sitemap.xml') return send(200, 'application/xml', `<?xml version="1.0"?><urlset>${PAGES.map((p) => `<url><loc>${www}${p}</loc></url>`).join('')}</urlset>`);
48
- if (PAGES.includes(req.url)) return send(200, 'text/html; charset=utf-8', page(req.url));
49
- return send(404, 'text/plain', 'нет');
50
- });
51
- await new Promise((r) => server.listen(0, '127.0.0.1', r));
52
- const base = `http://127.0.0.1:${server.address().port}`;
53
-
54
- // Английские подписи, которые сборщик писал сам до 0.33.1, и общий признак английской фразы.
55
- const ENGLISH = /\b(Week|Weeks|Months|Critical, quick wins|Foundation|Organic growth|Fix:|CMS \/ stack|Pages in sitemap|Language|What is sold|The site itself|Free, no account|read exactly as|sampled page|Search Console: no|Analytics: no|Keyword and backlink|external tier|Entity clarity|Third-party mentions|Reviews and PR|Contact form|Price visible|Messengers|Paid tool)\b/;
56
- const strings = (o, at = '', out = []) => {
57
- if (typeof o === 'string') out.push([at, o]);
58
- else if (Array.isArray(o)) o.forEach((x, i) => strings(x, `${at}[${i}]`, out));
59
- else if (o && typeof o === 'object') for (const k of Object.keys(o)) strings(o[k], at ? `${at}.${k}` : k, out);
60
- return out;
61
- };
62
- // Что пишет сам сборщик. Проверки, выборка и сырые замеры несут адреса и значения со страниц.
63
- const OWN = ['overview', 'critical', 'geo', 'conversion', 'sources', 'limitations', 'roadmap'];
64
-
65
- try {
66
- const a = await collect(`${base}/`, { pages: 4, pageDelayMs: 0, lang: 'ru', log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
67
- const pol = a.checks.find((c) => c.id === 'trust-policies') || {};
68
- is('политика и согласие найдены, проверка зелёная', pol.status, 'ok');
69
- ok(`и подписаны по-русски (${pol.value})`, /политика/.test(pol.value) && /согласие/.test(pol.value) && !/privacy|consent/.test(pol.value));
70
-
71
- const leaks = strings(Object.fromEntries(OWN.map((k) => [k, a[k]]))).filter(([, s]) => ENGLISH.test(s) && !s.includes('{{'));
72
- is('в русском audit.json нет английских подписей', leaks, []);
73
- is('план работ', a.roadmap.map((p) => p.badge), ['Неделя 1', 'Недели 2-4', 'Месяцы 2-3']);
74
- const badRu = a.checks.filter((c) => c.status === 'bad').map((c) => `Исправить: ${c.label}`);
75
- ok(`пункты плана по русским названиям проверок (${a.roadmap[0].items[0]})`, badRu.length > 0 && JSON.stringify(a.roadmap[0].items.slice(0, -1)) === JSON.stringify(badRu));
76
- ok('источник «Сам сайт» с числом страниц словами', a.sources[0].name === 'Сам сайт' && /и 4 страницы выборки/.test(a.sources[0].detail));
77
-
78
- const d = draftNarrative(a, { lang: 'ru' });
79
- const after = strings(Object.fromEntries(OWN.map((k) => [k, d[k]]))).filter(([, s]) => ENGLISH.test(s));
80
- is('и после черновика тоже', after, []);
81
- is('черновик нашёл русские строки обзора', d.overview.rows.map((r) => r[1].includes('{{')), [false, false, false, false]);
82
-
83
- const dir = await mkdtemp(path.join(tmpdir(), 'operstack-ru-'));
84
- const out = path.join(dir, 'ru.html');
85
- await render(d, { out });
86
- const text = (await readFile(out, 'utf8')).replace(/<style[\s\S]*?<\/style>|<script[\s\S]*?<\/script>/gi, ' ').replace(/<[^>]+>/g, ' ');
87
- const m = text.match(ENGLISH);
88
- ok(`в напечатанном отчёте тоже${m ? `: «${text.slice(Math.max(0, m.index - 60), m.index + 60).replace(/\s+/g, ' ')}»` : ''}`, !m);
89
-
90
- const en = await collect(`${base}/`, { pages: 4, pageDelayMs: 0, lang: 'en', log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
91
- is('английский отчёт не изменился', [en.roadmap[0].badge, en.sources[0].name, en.overview.rows[1][0], en.limitations.length], ['Week 1', 'The site itself', 'Pages in sitemap', 3]);
92
- } finally { server.close(); }
93
-
94
- if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
95
- console.log('\nвсё сходится');
@@ -1,41 +0,0 @@
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);
@@ -1,50 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Мы меряем то, что человек станет чинить, и говорим ему, что именно измерили.
4
- *
5
- * 16.09.2026 живой пользователь прошёл путь с 63 до 95 и назвал два узких места подряд:
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 src = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), 'visibility.mjs'), 'utf8');
15
-
16
- let bad = 0;
17
- const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
18
-
19
- ok('главная входит в оценку содержания наравне с остальными',
20
- /const contentPages = \[homePage, \.\.\.sampled\]/.test(src));
21
- ok('сортировки по длине адреса по убыванию больше нет',
22
- !/sort\(\(a, b\) => b\.length - a\.length\)/.test(src));
23
- ok('страницы отбираются по близости к корню', /depth\(a\) - depth\(b\)/.test(src));
24
- ok('и по одной из каждого раздела', /usedSections/.test(src));
25
- ok('отчёт называет измеренные страницы', /measured: \(list\)/.test(src) && /T\.measured\(/.test(src));
26
- ok('находка про абзац-ответ называет страницы', /answerFirstMissing: \(n, total, where\)/.test(src));
27
-
28
- // Отбор должен быть строго определённым: два прогона по одному сайту берут одни и те же страницы,
29
- // иначе балл гуляет и человек не понимает, что изменилось от его правки.
30
- const depth = (u) => new URL(u).pathname.replace(/\/$/, '').split('/').filter(Boolean).length;
31
- const section = (u) => new URL(u).pathname.split('/').filter(Boolean)[0] || '';
32
- const pick = (pages, want) => {
33
- const byDepth = [...pages].sort((a, b) => depth(a) - depth(b) || a.length - b.length || (a < b ? -1 : 1));
34
- const out = []; const used = new Set();
35
- for (const p of byDepth) { if (out.length >= want) break; const s = section(p); if (used.has(s)) continue; used.add(s); out.push(p); }
36
- for (const p of byDepth) { if (out.length >= want) break; if (!out.includes(p)) out.push(p); }
37
- return out;
38
- };
39
- const site = [
40
- 'https://x.ru/blog/rasshifrovka-vypuska-nomer-sorok-dva-pro-vsyo-na-svete/',
41
- 'https://x.ru/blog/rasshifrovka-vypuska-nomer-sorok-tri-pro-vsyo-na-svete/',
42
- 'https://x.ru/uslugi/', 'https://x.ru/ceny/', 'https://x.ru/o-nas/', 'https://x.ru/blog/',
43
- ];
44
- const got = pick(site, 3);
45
- ok('расшифровки больше не вытесняют витрину', !got.some((u) => u.includes('rasshifrovka')));
46
- ok('взяты разные разделы', new Set(got.map(section)).size === got.length);
47
- ok('отбор повторяем', JSON.stringify(pick(site, 3)) === JSON.stringify(got));
48
-
49
- console.log(bad ? `\nпровалов: ${bad}` : '\nвсё сходится');
50
- process.exit(bad ? 1 : 0);