@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,46 +0,0 @@
1
- #!/usr/bin/env node
2
- /** Агентная поверхность: три проверки, их перевод, работа и задание для агента. */
3
- import { FIX_ACTIONS } from './fix.mjs';
4
- import { localiseChecks } from './i18n.mjs';
5
- import { agentPrompt } from './prompts.mjs';
6
- import { draftNarrative } from './narrative.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 ids = ['agent-card', 'api-catalog', 'agent-markdown'];
13
-
14
- // У каждой проверки есть решение и перевод
15
- for (const id of ids) ok(`у проверки ${id} описано, что делать или почему не делаем`, Boolean(FIX_ACTIONS[id] && (FIX_ACTIONS[id].action || FIX_ACTIONS[id].why)));
16
- const ru = localiseChecks(ids.map((id) => ({ id, group: 'geo', label: 'English label', status: 'warn', value: 'not present', comment: 'an early, optional standard: it is the file agent directories read to learn who you are and what you offer' })), 'ru');
17
- for (const r of ru) ok(`проверка ${r.id} переведена`, !/English label/.test(r.label) && !/not present/.test(r.value));
18
-
19
- // Каталог API мы не обещаем чинить: он нужен не всем
20
- is('каталог API не обещаем', FIX_ACTIONS['api-catalog'].scope, 'never');
21
- ok('и объяснено почему', /есть API/.test(FIX_ACTIONS['api-catalog'].why.ru));
22
-
23
- // Задания для агента полные и не врут про обязательность
24
- const card = agentPrompt({ id: 'agent-card', label: 'Карточка агента', status: 'warn', value: 'нет' }, { lang: 'ru' });
25
- ok('в задании про карточку есть проверка', /Как проверить/.test(card));
26
- ok('и не велит делать карточку без агента', /только правду|выдуманная возможность|ради галочки нельзя/.test(card));
27
- const md = agentPrompt({ id: 'agent-markdown', label: 'Markdown-версия', status: 'warn', value: 'нет' }, { lang: 'ru' });
28
- ok('в задании про markdown сказано, что класть, а что нет', /меню и подвал/.test(md));
29
-
30
- // В отчёте это не превращается в приговор: стандарт ранний
31
- const audit = {
32
- meta: { host: 'x.ru', lang: 'ru' },
33
- scores: { 'GEO, visibility in AI systems': 7 },
34
- client: { name: '{{a}}', subject: '{{b}}' },
35
- summary: { lead: '{{a}}', verdict: '{{b}}', priorities: ['{{1}}'] },
36
- overview: { rows: [], note: '{{n}}' }, content: { strengths: [], weaknesses: [], gaps: [] },
37
- aeo: { works: [], blocks: [], recommendation: '{{r}}' }, geo: { rows: [], callout: '{{c}}' },
38
- offpage: { listed: [], note: '{{n}}' }, conversion: { rows: [] }, roadmap: [], closing: '{{c}}',
39
- sample: [], checks: [{ id: 'agent-card', group: 'geo', label: 'Карточка агента', status: 'warn', value: 'нет' }],
40
- };
41
- const d = draftNarrative(audit, { lang: 'ru' });
42
- ok('в приоритетах появляется работа по карточке', /agent\.json|карточк/i.test(JSON.stringify(d.summary.priorities)));
43
- ok('и вывод не называет это провалом', !/критичн/i.test(d.summary.verdict) || /нет/.test(d.summary.verdict));
44
-
45
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
46
- console.log('\nвсе тесты агентной поверхности прошли');
@@ -1,27 +0,0 @@
1
- /**
2
- * Ответ в первом абзаце (15.10.2026, cimes.ru): вводный блок после H1 из нескольких абзацев засчитывается целиком,
3
- * если вместе он укладывается в 20-90 слов. Длинная статья без подзаголовков по-прежнему мерится первым абзацем.
4
- */
5
- import { analysePage } from './collect.mjs';
6
-
7
- let bad = 0;
8
- const is = (n, a, b) => { if (a !== b) { bad++; console.error(`FAIL ${n}: ждали ${b}, вышло ${a}`); } else console.log(`ok ${n}`); };
9
- const page = (body) => `<html><head><title>T</title></head><body><main>${body}</main></body></html>`;
10
-
11
- const cimes = page(`<h1>Создание сайта в Новороссийске и любом другом городе России</h1><div class="text">
12
- <p>Разрабатываем сайты, интернет-магазины и CRM — на 1С-Битрикс и на собственном коде, с интеграциями с 1С, онлайн-оплатой и службами доставки.</p>
13
- <p class="hero-prices">Визитка — от 28 000 ₽ · корпоративный — от 78 000 ₽ · магазин — от 120 000 ₽. Смета бесплатно за 1–2 рабочих дня.</p></div>
14
- <div class="buttons"><button class="btn">Обсудить проект</button></div><h2>Услуги</h2><p>${'Текст раздела. '.repeat(40)}</p>`);
15
- is('cimes: ответ двумя абзацами до кнопок засчитан', analysePage('https://cimes.ru/', cimes).answerFirst, true);
16
-
17
- const longLead = page(`<h1>Статья</h1><p>${'Слово '.repeat(30)}и 5 причин.</p>${`<p>${'Ещё текст абзаца без цифр. '.repeat(10)}</p>`.repeat(4)}`);
18
- is('длинный вводный блок: мерится первый абзац', analysePage('https://x.ru/a/', longLead).answerFirst, true);
19
-
20
- const shortNoFigure = page(`<h1>Услуга</h1><p>Делаем сайты под ключ для бизнеса.</p><button>Заказать</button><p>${'Описание без цифр. '.repeat(20)}</p>`);
21
- is('короткий абзац до кнопки без цифры: ответа нет', analysePage('https://x.ru/b/', shortNoFigure).answerFirst, false);
22
-
23
- const techWords = page(`<h1>Дизайн сайтов</h1><p>Делаем дизайн и вёрстку сайтов на HTML5 и CSS3 с адаптивом под телефоны и планшеты, наполняем страницы текстами и фотографиями для бизнеса.</p><button>Заказать</button>`);
24
- is('HTML5 и CSS3 не цифра ответа', analysePage('https://x.ru/c/', techWords).answerFirst, false);
25
-
26
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
27
- console.log('\nответ в первом абзаце: все случаи сходятся');
@@ -1,100 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Сайт нас не пустил. Что мы имеем право сказать про него владельцу.
4
- *
5
- * До 15.09.2026 проверка писала: «Роботы ИИ её не проходят, поэтому ChatGPT, Perplexity и
6
- * остальных разворачивают ровно так же». Это вывод о ЧУЖОМ сайте, сделанный из того, что
7
- * развернули НАС. Замерено руками на alternativeto.net: тот же адрес отдаёт GPTBot, ClaudeBot
8
- * и PerplexityBot полную страницу (200, около 900 КБ), а нашей проверке 403. Защита держит
9
- * белый список проверенных роботов.
10
- *
11
- * Здесь держится граница: говорим, что не пустили нас, и что написано в правилах сайта.
12
- * Про поведение защиты не гадаем.
13
- */
14
- import { blockedNote } 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
- const OPEN = 'User-agent: *\nDisallow: /admin/\n';
20
- const CLOSED = 'User-agent: GPTBot\nDisallow: /\n\nUser-agent: ClaudeBot\nDisallow: /\n';
21
-
22
- // ---- главное: ни при каких вводных не утверждать, что роботов ИИ развернули
23
- {
24
- for (const lang of ['ru', 'en']) {
25
- for (const [what, robots] of [['правила открыты', OPEN], ['правила закрыты', CLOSED], ['robots не прочитан', null]]) {
26
- for (const challenged of [true, false]) {
27
- const t = blockedNote({ status: 403, challenged, robotsText: robots, lang }).text;
28
- ok(`${lang}/${what}/${challenged ? 'защита' : 'отказ'}: не утверждаем, что роботов ИИ развернули`, lang === 'ru'
29
- ? !/(разворачивают|не проходят|получают тот же ответ|процитировать (здесь|с сайта) нельзя|нечего)/.test(t.replace('сказать нечего', ''))
30
- : !/(turned away|do not run one|get the same answer|nothing here can be read)/.test(t));
31
- ok(`${lang}/${what}/${challenged ? 'защита' : 'отказ'}: сказано, как проверить точно`, lang === 'ru' ? /журнале сервера/.test(t) : /server log/.test(t));
32
- }
33
- }
34
- }
35
- }
36
-
37
- // ---- что именно говорим в каждом из трёх случаев
38
- {
39
- const ru = (robots) => blockedNote({ status: 403, challenged: true, robotsText: robots, lang: 'ru' });
40
- const en = (robots) => blockedNote({ status: 403, challenged: true, robotsText: robots, lang: 'en' });
41
-
42
- const open = ru(OPEN);
43
- ok('правила открыты: сказано, что пускают', /правила самого сайта роботов ИИ при этом пускают/i.test(open.text));
44
- ok('правила открыты: сказано, что не зачтено в минус', /не зачтено/.test(open.text));
45
- ok('правила открыты: названы конкретные роботы', /OAI-SearchBot|GPTBot/.test(open.text));
46
- ok('правила открыты: поле говорит «не закрыты»', open.botsBlocked === false);
47
-
48
- const closed = ru(CLOSED);
49
- ok('правила закрыты: названо письменным отказом', /письменный отказ/.test(closed.text));
50
- ok('правила закрыты: названы именно закрытые', /GPTBot/.test(closed.text) && /Claude/.test(closed.text));
51
- ok('правила закрыты: поле говорит «закрыты»', closed.botsBlocked === true);
52
-
53
- const none = ru(null);
54
- ok('robots не прочитан: молчим про роботов', /про роботов ИИ сказать нечего/.test(none.text));
55
- ok('robots не прочитан: и это не в минус', /не зачтено/.test(none.text));
56
- ok('robots не прочитан: поле пустое, а не false', none.botsBlocked === null);
57
-
58
- ok('английский: правила открыты', /rules do allow the AI crawlers/.test(en(OPEN).text));
59
- ok('английский: правила закрыты', /rules also close it to AI crawlers/.test(en(CLOSED).text));
60
- ok('английский: robots не прочитан', /robots\.txt could not be read/.test(en(null).text));
61
- }
62
-
63
- // ---- защита и простой отказ описываются по-разному: лечатся они тоже по-разному
64
- {
65
- const ch = blockedNote({ status: 403, challenged: true, robotsText: OPEN, lang: 'ru' }).text;
66
- const wall = blockedNote({ status: 403, challenged: false, robotsText: OPEN, lang: 'ru' }).text;
67
- ok('защита названа защитой браузера', /проверкой браузера \(Cloudflare/.test(ch));
68
- ok('простой отказ назван отказом незнакомым клиентам', /отказывает незнакомым клиентам/.test(wall));
69
- ok('код ответа назван в обоих', /403/.test(ch) && /403/.test(wall));
70
- }
71
-
72
- // ---- чужого языка в тексте нет: его читает человек
73
- {
74
- const ruText = blockedNote({ status: 403, challenged: true, robotsText: OPEN, lang: 'ru' }).text;
75
- // Имена роботов это их настоящие идентификаторы, они латиницей и на русском тоже: ChatGPT-User
76
- // нельзя перевести, иначе владелец не найдёт его в журнале сервера. Вычёркиваем их и смотрим,
77
- // не осталось ли непереведённых слов.
78
- const bare = ruText.replace(/ChatGPT-User|OAI-SearchBot|Claude-SearchBot|Claude-User|Applebot-Extended|Google-Extended|anthropic-ai|PerplexityBot|Perplexity-User|Cloudflare|GPTBot|ClaudeBot|ChatGPT|OpenAI|Perplexity|Claude|robots\.txt|Anthropic|Gemini|Bingbot|Copilot|Bing|Apple Intelligence/g, '');
79
- ok('в русском тексте не осталось английских слов', !/[A-Za-z]{4}/.test(bare));
80
- ok('в английском нет кириллицы', !/[А-Яа-яЁё]/.test(blockedNote({ status: 403, challenged: true, robotsText: OPEN, lang: 'en' }).text));
81
- }
82
-
83
- // ---- несуществующий адрес: человек чаще всего просто опечатался
84
- //
85
- // Стояло «Сайт ответил ничем на https://…»: и коряво, и бесполезно. Человек, который ошибся в
86
- // букве, должен узнать об этом первым делом, а не гадать, что с его сайтом.
87
- {
88
- const { checkVisibility } = await import('./visibility.mjs');
89
- for (const lang of ['ru', 'en']) {
90
- const v = await checkVisibility('https://takogo-domena-tochno-net-4f7a2b9c.ru/', { budgetMs: 8500, samplePages: 3, lang });
91
- ok(`${lang}: балла у несуществующего адреса нет`, v.ok === false);
92
- ok(`${lang}: названа вероятная причина, опечатка`, lang === 'ru' ? /опечатка в адресе/.test(v.error) : /typo in the address/.test(v.error));
93
- ok(`${lang}: сказано, что делать`, lang === 'ru' ? /проверьте написание/.test(v.error) : /check the spelling/.test(v.error));
94
- ok(`${lang}: нет старого «ответил ничем»`, lang === 'ru' ? !/ответил ничем/.test(v.error) : !/answered nothing/.test(v.error));
95
- ok(`${lang}: чужого языка нет`, lang === 'ru' ? !/[A-Za-z]{5}/.test(v.error.replace(/https?:\/\/[^\s]+/g, '')) : !/[А-Яа-яЁё]/.test(v.error));
96
- }
97
- }
98
-
99
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
100
- console.log('\nпро чужой сайт говорим только измеренное');
@@ -1,87 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Сайт без карты сайта читается по ссылкам, а не одной главной.
4
- *
5
- * 30.09.2026 elitelivingrealty.com и solohomes.co.uk (на www нет ни robots.txt, ни карты) дали
6
- * в отчёте выборку из одной главной. Здесь: разбор ссылок и правил robots.txt отдельно, потом
7
- * живой сервер без карты, где страницы находятся только по ссылкам, на двух уровнях.
8
- */
9
- import { createServer } from 'node:http';
10
- import { lookup } from 'node:dns/promises';
11
- import { internalLinks, robotsAllows } from './visibility.mjs';
12
- import { collect } from './collect.mjs';
13
- import { checkVisibility } from './visibility.mjs';
14
-
15
- let bad = 0;
16
- const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
17
- const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
18
-
19
- // ---- разбор ссылок
20
- {
21
- const html = `<a href="/buy/">Buy</a><a href="/buy">dup</a><a href="https://www.x.com/rent/?page=2#top">Rent</a>
22
- <a href="https://x.com/sell/">apex</a><a href="https://other.com/">other</a><a href="mailto:a@x.com">m</a>
23
- <a href="tel:+1">t</a><a href="#top">anchor</a><a href="/brochure.pdf">pdf</a><a href="/about.html">about</a>
24
- <a href="/cart/">cart</a><a href="/tag/villas/">tag</a><a href="/x/" rel="nofollow">nf</a><a href="javascript:void(0)">js</a>`;
25
- is('свои страницы, без повторов, файлов и служебных', internalLinks(html, 'https://www.x.com/'),
26
- ['https://www.x.com/buy/', 'https://www.x.com/rent/', 'https://x.com/sell/', 'https://www.x.com/about.html']);
27
- is('относительные ссылки от <base>', internalLinks('<base href="https://www.x.com/en/"><a href="team/">t</a>', 'https://www.x.com/'), ['https://www.x.com/en/team/']);
28
- is('битый html не роняет разбор', internalLinks(undefined, 'https://x.com/'), []);
29
- }
30
-
31
- // ---- robots.txt
32
- {
33
- const allow = robotsAllows('User-agent: Googlebot\nDisallow: /\n\nUser-agent: *\nDisallow: /private/\nDisallow: /*.php$\nAllow: /private/open/\n');
34
- ok('обычная страница разрешена', allow('https://x.com/buy/'));
35
- ok('закрытый раздел нет', !allow('https://x.com/private/a/'));
36
- ok('Allow длиннее Disallow побеждает', allow('https://x.com/private/open/a/'));
37
- ok('звёздочка и доллар', !allow('https://x.com/a/b.php') && allow('https://x.com/a/b.php5'));
38
- ok('блок чужого робота не применяется', allow('https://x.com/'));
39
- ok('пустой robots.txt разрешает всё', robotsAllows('')('https://x.com/a/'));
40
- }
41
-
42
- // ---- живой сервер без карты сайта
43
- let dnsOk = true;
44
- try { dnsOk = (await lookup('lvh.me')).address === '127.0.0.1'; } catch { dnsOk = false; }
45
- if (!dnsOk) { console.log('ok пропуск живой части: lvh.me не указывает на 127.0.0.1 (нет сети)'); process.exit(bad ? 1 : 0); }
46
-
47
- const LINKS = {
48
- '/': ['/buy/', '/rent/', '/private/', '/contact/', 'https://elsewhere.example/'],
49
- '/buy/': ['/buy/villas/', '/buy/flats/', '/'],
50
- '/rent/': ['/rent/long-term/'],
51
- '/contact/': [], '/buy/villas/': [], '/buy/flats/': [], '/rent/long-term/': [], '/private/': [],
52
- };
53
- const page = (path) => `<!doctype html><html lang="en"><head><title>Page ${path}</title>
54
- <meta name="description" content="A page description long enough not to count as empty for the check."></head>
55
- <body><main><h1>Page ${path}</h1>${'<p>Plain paragraph about the property, the area and the price.</p>'.repeat(10)}
56
- <nav>${LINKS[path].map((l) => `<a href="${l}">${l}</a>`).join('')}</nav></main></body></html>`;
57
- const hits = [];
58
- const server = createServer((req, res) => {
59
- hits.push(req.url);
60
- if (req.url === '/robots.txt') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end('User-agent: *\nDisallow: /private/\n'); }
61
- if (LINKS[req.url]) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(page(req.url)); }
62
- res.writeHead(404, { 'content-type': 'text/plain' }); res.end('no');
63
- });
64
- await new Promise((r) => server.listen(0, '127.0.0.1', r));
65
- const base = `http://lvh.me:${server.address().port}`;
66
-
67
- try {
68
- const a = await collect(`${base}/`, { pages: 10, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
69
- const read = a.sample.filter((p) => p.title !== undefined).map((p) => new URL(p.url).pathname);
70
- ok('карты нет, и отчёт это говорит', (a.checks.find((c) => c.id === 'sitemap') || {}).status === 'bad');
71
- is('прочитаны все открытые страницы по ссылкам, двумя уровнями', [...read].sort(), ['/', '/buy/', '/buy/flats/', '/buy/villas/', '/contact/', '/rent/', '/rent/long-term/']);
72
- ok('закрытое в robots.txt не читается', !hits.includes('/private/'));
73
- ok('страница контактов идёт раньше остальных ссылок', read.indexOf('/contact/') < read.indexOf('/buy/'));
74
- is('счёт найденных по ссылкам', a.reading.fromLinks, 6);
75
- ok('в источниках сказано, откуда страницы', /found by following links/.test(a.sources[0].detail));
76
-
77
- const few = await collect(`${base}/`, { pages: 3, pageDelayMs: 0, log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
78
- is('предел страниц соблюдается', few.sample.length, 3);
79
-
80
- const v = await checkVisibility(`${base}/`, { lang: 'en', samplePages: 5, budgetMs: 8000 });
81
- ok('бесплатная проверка прочитала сайт', v.ok);
82
- const vRead = (v.sample || []).map((p) => new URL(p.url).pathname);
83
- ok(`бесплатная проверка взяла страницы со ссылок главной (${vRead.join(', ')})`, vRead.length >= 4 && vRead.includes('/buy/') && !vRead.includes('/private/'));
84
- } finally { server.close(); }
85
-
86
- if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
87
- console.log('\nвсё сходится');
@@ -1,243 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Карта спроса: зёрна, подсказки, частоты, покрытие, вывод.
4
- *
5
- * Здесь держится пять вещей. Зёрна берутся из заголовков без слов бренда. Подсказки
6
- * фильтруются по общему слову с зерном и делятся поровну между зёрнами. Клиент Topvisor
7
- * ходит по методам в правильном порядке, спрашивает цену до запуска и в сухом прогоне не
8
- * запускает ничего. Покрытие считается по словам адреса и заголовка. В выводе нет ни null,
9
- * ни undefined, ни нуля вместо «нет данных», и доли сходятся с таблицей.
10
- */
11
- import { createServer } from 'node:http';
12
- import { readFileSync } from 'node:fs';
13
- import { toHtml } from './render.mjs';
14
- import { verifyScores, computeScores } from './collect.mjs';
15
- import { anchorsFromPages, enrichTitles, tokens, seedsFromPages, expandQueries, coverage, sitemapPages, topvisorClient, volumesViaTopvisor, buildDemandMap, renderDemandHtml, renderDemandMarkdown, demandSourceRow, demandCsv, demandTotals, PATTERNS, REGIONS } from './demand.mjs';
16
-
17
- let bad = 0;
18
- const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
19
- const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
20
-
21
- // ---- слова
22
- is('основа слова: квартира и квартиры совпадают', [...tokens('квартиру в сочи', 'ru')].sort(), [...tokens('квартира сочи', 'ru')].sort());
23
- is('кириллица и латинский адрес сходятся к одной основе', tokens('квартиры у моря', 'ru'), tokens('kvartiry u morya', 'ru'));
24
- is('варианты транслитерации схлопнуты', tokens('площадь хутор цена', 'ru'), tokens('ploshchad khutor', 'ru'));
25
- ok('служебные слова выпадают', !tokens('как купить квартиру в сочи', 'ru').includes('как'));
26
- ok('английские тоже, и «buy» не различает страницы', JSON.stringify(tokens('how to buy a condo in miami', 'en')) === JSON.stringify(['condo', 'miami']));
27
- is('ё и е одно и то же', tokens('ещё жильё', 'ru'), tokens('еще жилье', 'ru'));
28
-
29
- // ---- зёрна
30
- {
31
- const pages = [
32
- { url: 'https://invest-cambodia.com/', title: 'Invest Cambodia | Real estate in Phnom Penh', h1: 'Real estate in Phnom Penh' },
33
- { url: 'https://invest-cambodia.com/visa/', title: 'Cambodia retirement visa: rules 2026 - Invest Cambodia', h1: 'Cambodia retirement visa' },
34
- { url: 'https://invest-cambodia.com/contact/', title: 'Contact us', h1: 'Contact us' },
35
- ];
36
- const s = seedsFromPages(pages, { host: 'www.invest-cambodia.com' });
37
- ok('бренд вырезан из зёрен', !s.some((x) => /invest/.test(x)) && s.includes('real estate in phnom penh'));
38
- ok('заголовок разбит по разделителям', s.includes('retirement visa: rules 2026'.replace(': rules 2026', '')) || s.includes('retirement visa') || s.includes('cambodia retirement visa'));
39
- ok('«contact us» не зерно', !s.includes('contact us'));
40
- ok('дубли схлопнуты', new Set(s).size === s.length);
41
- is('лимит зёрен работает', seedsFromPages(pages, { host: 'x.com', max: 1 }).length, 1);
42
- // Главная MORE Group 15.09.2026: заголовок с брендом и сроком, H1 с ценой, меню из кнопок.
43
- const mg = seedsFromPages([{
44
- url: 'https://moregroupestate.ru/', title: 'MORE Group: агентство недвижимости на Пхукете. Подбор объекта за 2 часа', h1: 'Новые проекты на Пхукете от 4,6 млн ₽',
45
- h2: ['Получите подборку под ваш бюджет', 'Районы Пхукета', 'Калькулятор доходности', 'Почему инвестировать в Пхукет?'],
46
- nav: ['MORE Group', 'О компании', 'Подборка за 2 часа', 'Получить подборку за 2 часа', 'M MAX без VPN', 'TG Telegram', 'WA WhatsApp'],
47
- }], { host: 'moregroupestate.ru', brand: 'MORE Group' });
48
- is('H1 без цены идёт первым', mg[0], 'новые проекты на пхукете');
49
- ok('тема из заголовка после двоеточия', mg.includes('агентство недвижимости на пхукете'));
50
- ok('подзаголовки дают темы, если держатся за якорь', mg.includes('районы пхукета') && mg.includes('почему инвестировать в пхукет'));
51
- ok('подзаголовок без якоря (лозунг) не зерно', !mg.includes('гибкие планы оплаты') && !mg.includes('калькулятор доходности'));
52
- ok('кнопки, мессенджеры и бренд не зёрна', !mg.some((x) => /подборк|telegram|whatsapp|vpn|more group|о компании/.test(x)));
53
- const an = anchorsFromPages([{ title: 'MORE Group: агентство недвижимости на Пхукете', h1: 'Новые проекты на Пхукете от 4,6 млн ₽' }], { host: 'moregroupestate.ru', brand: 'MORE Group', lang: 'ru' });
54
- ok('якоря: тема без бренда, чисел и единиц', an.has('phuke') && an.has('nedvi') && !an.has('more') && !an.has('group') && !an.has('mln') && ![...an].some((t) => /\d/.test(t)));
55
- const an2 = anchorsFromPages([{ title: 'MORE Group: агентство недвижимости на Пхукете. Подбор объекта за 2 часа', h1: '' }], { host: 'moregroupestate.ru', brand: 'MORE Group', lang: 'ru' });
56
- ok('срок из заголовка («за 2 часа») не якорь', !an2.has('chasa') && an2.has('podbo'));
57
- const cam = seedsFromPages([{ title: 'Invest Cambodia: property investment in Phnom Penh', h1: 'Property investment for foreign buyers', h2: ['What changed', 'Who should not buy here', 'Areas of Phnom Penh'], nav: ['Get your shortlist'] }], { host: 'invest-cambodia.com', lang: 'en' });
58
- ok('лозунги главной не зёрна, а раздел про районы да', !cam.includes('what changed') && !cam.includes('who should not buy here') && cam.includes('areas of phnom penh'));
59
- const camAn = anchorsFromPages([{ title: 'Property, measured before it is sold', h1: 'Property investment for foreign buyers' }], { host: 'invest-cambodia.com', lang: 'en' });
60
- ok('география из имени домена стала якорем, а общее слово «invest» нет', camAn.has('cambo') && !camAn.has('inves'));
61
- const mgAn = anchorsFromPages([{ title: 'MORE Group: агентство недвижимости на Пхукете', h1: '' }], { host: 'moregroupestate.ru', brand: 'MORE Group', lang: 'ru' });
62
- ok('склеенное имя домена якорем не стало', !mgAn.has('moreg'));
63
- const rep = seedsFromPages([{ title: 'Виллы на Пхукете | Юг-Дом', h1: '' }, { title: 'Дома у моря | Юг-Дом', h1: '' }], { host: 'x.ru' });
64
- ok('повторяющийся сегмент заголовка признан брендом', rep.includes('виллы на пхукете') && !rep.some((x) => /юг-дом/.test(x)));
65
- }
66
-
67
- // ---- подсказки с поддельным автодополнением
68
- {
69
- const fake = async (url) => {
70
- const q = decodeURIComponent((url.match(/[?&](?:q|part)=([^&]*)/) || [])[1] || '');
71
- if (/сломан/.test(q)) throw new Error('boom');
72
- const base = q.replace(/^(как|где|что такое|сколько стоит)\s+/, '').replace(/\s+(цена|отзывы|или)$/, '');
73
- return [q, [`${base} недорого`, `${base} у моря`, 'совсем другое', 'один', `${q} 2026`]];
74
- };
75
- const r = await expandQueries(['квартира в сочи', 'дом в сломанном месте'], { lang: 'ru', region: 225, max: 6, delayMs: 0, fetchJson: fake, engines: ['yandex'] });
76
- is('обращений столько, сколько шаблонов на зерно', r.calls, PATTERNS.ru.length * 2);
77
- ok('падение одного зерна не роняет прогон', r.failed === PATTERNS.ru.length && r.queries.length > 0);
78
- ok('чужие фразы отфильтрованы по общему слову', !r.queries.includes('совсем другое'));
79
- ok('фразы из одного слова выпали', !r.queries.includes('один'));
80
- ok('лимит соблюдён', r.queries.length <= 6);
81
- ok('само зерно в списке первым', r.queries[0] === 'квартира в сочи');
82
- const two = await expandQueries(['alpha beta', 'gamma delta'], { lang: 'en', region: 84, max: 4, delayMs: 0, engines: ['google'], fetchJson: async (u) => { const q = decodeURIComponent((u.match(/q=([^&]*)/) || [])[1]); const b = q.replace(/^(how to|what is|best)\s+/, '').replace(/\s+(cost|price|near me|vs)$/, ''); return [q, [`${b} one`, `${b} two`, `${b} three`]]; } });
83
- ok('квота делится между зёрнами поровну', two.queries.filter((q) => q.startsWith('alpha')).length === 2 && two.queries.filter((q) => q.startsWith('gamma')).length === 2);
84
- const anchored = await expandQueries(['квартира в сочи'], { lang: 'ru', region: 225, max: 20, delayMs: 0, engines: ['yandex'], anchors: new Set(['sochi']), fetchJson: async () => ['x', ['квартира в сочи недорого', 'квартира в анапе недорого', 'квартиры сочи у моря']] });
85
- ok('без якорного слова запрос выпадает, даже если делит слово с зерном', anchored.queries.includes('квартира в сочи недорого') && anchored.queries.includes('квартиры сочи у моря') && !anchored.queries.includes('квартира в анапе недорого'));
86
- }
87
-
88
- // ---- покрытие
89
- {
90
- const pages = [{ url: 'https://x.ru/kvartiry-v-sochi/', title: '', h1: '' }, { url: 'https://x.ru/blog/post-1/', title: 'Дом у моря: цены 2026', h1: 'Дом у моря' }];
91
- const c = coverage(['купить квартиру в сочи', 'дом у моря цена', 'вилла в анапе'], pages, { lang: 'ru' });
92
- ok('покрытие по латинскому адресу русского сайта', c[0].covered && c[0].url === 'https://x.ru/kvartiry-v-sochi/');
93
- ok('покрытие по заголовку', c[1].covered && /post-1/.test(c[1].url));
94
- ok('нет страницы, значит нет', !c[2].covered && c[2].url === null);
95
- const en = coverage(['buy condo miami', 'phuket villa rental', 'rent condo miami'], [{ url: 'https://y.com/miami/condos-for-sale/', title: '', h1: '' }], { lang: 'en' });
96
- ok('«купить» не отличает страницу: сайт продажи отвечает на запрос про покупку', en[0].covered && !en[1].covered);
97
- ok('а «снять» отличает: страница продажи не про аренду', !en[2].covered);
98
- }
99
-
100
- // ---- карты сайта
101
- {
102
- const files = {
103
- 'https://x.ru/robots.txt': 'User-agent: *\nSitemap: https://x.ru/sitemap-index.xml\n',
104
- 'https://x.ru/sitemap-index.xml': '<sitemapindex><sitemap><loc>https://x.ru/s1.xml</loc></sitemap><sitemap><loc>https://x.ru/s2.xml</loc></sitemap></sitemapindex>',
105
- 'https://x.ru/s1.xml': '<urlset><url><loc>https://x.ru/a/</loc></url><url><loc>https://x.ru/b/</loc></url></urlset>',
106
- 'https://x.ru/s2.xml': '<urlset><url><loc>https://x.ru/c/</loc></url><url><loc> https://x.ru/a/ </loc></url></urlset>',
107
- };
108
- const p = await sitemapPages('https://x.ru/', { fetchText: async (u) => { if (!files[u]) throw new Error('404'); return files[u]; } });
109
- is('адреса из вложенных карт без дублей', p.map((x) => x.url), ['https://x.ru/a/', 'https://x.ru/b/', 'https://x.ru/c/']);
110
- const none = await sitemapPages('https://y.ru/', { fetchText: async () => { throw new Error('404'); } });
111
- is('без карты пустой список, не ошибка', none, []);
112
- }
113
-
114
- // ---- заголовки для английских адресов русского сайта
115
- {
116
- let fetched = 0;
117
- const html = { 'https://x.ru/districts/': '<title>Районы Пхукета: где купить | Юг-Дом</title><h1>Районы Пхукета</h1>', 'https://x.ru/districts/bang-tao/': '<title>Банг Тао</title>', 'https://x.ru/a/b/c/': '<title>Глубоко</title>' };
118
- const pages = [{ url: 'https://x.ru/a/b/c/', title: '', h1: '' }, { url: 'https://x.ru/districts/bang-tao/', title: '', h1: '' }, { url: 'https://x.ru/districts/', title: '', h1: '' }, { url: 'https://x.ru/dead/', title: '', h1: '' }];
119
- const done = await enrichTitles(pages, { max: 3, concurrency: 2, fetchText: async (u) => { fetched += 1; if (!html[u]) throw new Error('404'); return html[u]; } });
120
- ok('короткие адреса читаются первыми, лимит соблюдён', fetched === 3 && done === 2 && pages.find((p) => p.url === 'https://x.ru/a/b/c/').title === '' && pages.find((p) => p.url === 'https://x.ru/districts/bang-tao/').title === 'Банг Тао');
121
- ok('страница без ответа остаётся адресом, не ошибкой', pages.find((p) => p.url === 'https://x.ru/dead/').title === '');
122
- const c = coverage(['районы пхукета'], pages, { lang: 'ru' });
123
- ok('русский запрос нашёл страницу с английским адресом по заголовку', c[0].covered && c[0].url === 'https://x.ru/districts/');
124
- }
125
-
126
- // ---- Topvisor на поддельном сервере
127
- function fakeTopvisor() {
128
- const state = { projects: [{ id: 1, name: 'Другой', site: 'z.ru' }], searchers: {}, groups: [], keywords: [], launched: 0, polls: 0, calls: [] };
129
- const server = createServer((req, res) => {
130
- let raw = ''; req.on('data', (d) => { raw += d; });
131
- req.on('end', () => {
132
- const method = req.url.replace(/^\//, '');
133
- let body = {};
134
- const ct = req.headers['content-type'] || '';
135
- if (ct.startsWith('multipart/form-data')) {
136
- const b = ct.split('boundary=')[1];
137
- for (const part of raw.split(`--${b}`)) { const m = part.match(/name="([^"]+)"\r\n\r\n([\s\S]*?)\r\n$/); if (m) body[m[1]] = m[2]; }
138
- } else { try { body = JSON.parse(raw || '{}'); } catch { body = {}; } }
139
- state.calls.push({ method, body, auth: req.headers.authorization, uid: req.headers['user-id'] });
140
- const send = (result) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ result })); };
141
- const fail = (s) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ result: null, errors: [{ code: 2001, string: s }] })); };
142
- if (method === 'get/projects_2/projects') {
143
- const list = state.projects.filter((p) => !body.filters || body.filters.every((f) => f.values.includes(p.id)));
144
- return send(body.show_searchers_and_regions ? list.map((p) => ({ ...p, searchers: Object.entries(state.searchers[p.id] || {}).map(([k, regs]) => ({ key: Number(k), regions: regs.map((r) => ({ key: r })) })) })) : list);
145
- }
146
- if (method === 'add/projects_2/projects') { state.projects.push({ id: 7, name: body.name, site: body.url }); return send(7); }
147
- if (method === 'add/positions_2/searchers') { state.searchers[body.project_id] ||= {}; if (state.searchers[body.project_id][body.searcher_key]) return fail('Поисковая система уже добавлена'); state.searchers[body.project_id][body.searcher_key] = []; return send(1); }
148
- if (method === 'add/positions_2/searchers/regions') { state.searchers[body.project_id][body.searcher_key].push(body.region_key); return send(1); }
149
- if (method === 'add/keywords_2/groups') { if (!Array.isArray(body.name)) return fail('name must be array'); const id = 100 + state.groups.length; state.groups.push({ id, name: body.name[0], project_id: body.project_id }); return send([id]); }
150
- if (method === 'get/keywords_2/groups') return send(state.groups.filter((g) => g.project_id === body.project_id));
151
- if (method === 'add/keywords_2/keywords/import') { for (const k of String(body.keywords).split('\n')) state.keywords.push({ id: state.keywords.length + 1, name: k, group_id: Number(body.group_id) }); return send(state.keywords.length); }
152
- if (method === 'get/keywords_2/volumes/price') { const n = state.keywords.filter((k) => k.group_id === body.filters[0].values[0]).length; return send({ pricesByUsers: { 1: { price: Math.round(n * 2) / 100, limits: n } } }); }
153
- if (method === 'edit/keywords_2/volumes/go') { state.launched += 1; return send(true); }
154
- if (method === 'get/keywords_2/keywords') {
155
- state.polls += 1;
156
- const field = body.fields.find((f) => f.startsWith('volume:'));
157
- return send(state.keywords.filter((k) => k.group_id === body.filters[0].values[0]).map((k, i) => ({ id: k.id, name: k.name, [field]: state.polls >= 2 ? (i + 1) * 100 : null })));
158
- }
159
- return fail(`Call to undefined method: ${method}`);
160
- });
161
- });
162
- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ state, server, base: `http://127.0.0.1:${server.address().port}/` })));
163
- }
164
- {
165
- const { state, server, base } = await fakeTopvisor();
166
- const client = topvisorClient({ userId: '5', key: 'secret', base });
167
- const dry = await volumesViaTopvisor(['купить квартиру в сочи', 'дом у моря цена'], { lang: 'ru', region: 225, client, dry: true, groupName: 'x.ru 2026-09-15' });
168
- ok('сухой прогон: цена получена, ничего не запущено', dry.price === 0.04 && !dry.launched && state.launched === 0 && /dry/.test(dry.note));
169
- ok('заголовки с ключами на месте', state.calls.every((c) => c.auth === 'bearer secret' && c.uid === '5'));
170
- const order = state.calls.map((c) => c.method);
171
- ok('проект создан, потому что его не было', order.includes('add/projects_2/projects') && state.projects.some((p) => p.name === 'OperStack: карта спроса'));
172
- ok('цена спрошена после импорта и до запуска', order.indexOf('add/keywords_2/keywords/import') < order.indexOf('get/keywords_2/volumes/price') && !order.includes('edit/keywords_2/volumes/go'));
173
- const priceCall = state.calls.find((c) => c.method === 'get/keywords_2/volumes/price').body;
174
- is('определитель частоты: регион, Яндекс, тип 1', priceCall.qualifiers, [{ region_key: 225, searcher_key: 0, type: 1 }]);
175
- is('фильтр по группе, а не по всему проекту', priceCall.filters[0].name, 'group_id');
176
- const full = await volumesViaTopvisor(['купить квартиру в сочи', 'дом у моря цена'], { lang: 'ru', region: 225, client, groupName: 'x.ru 2026-09-15 b', pollMs: 1 });
177
- ok('полный прогон: запущено один раз, частоты заполнены', state.launched === 1 && full.launched && full.filled === 2 && full.volumes['купить квартиру в сочи'] === 100 && full.volumes['дом у моря цена'] === 200);
178
- ok('повторное добавление поисковика не ошибка', full.projectId === 7);
179
- const en = await volumesViaTopvisor(['buy condo miami'], { lang: 'en', region: 84, client, groupName: 'y.com', pollMs: 1 });
180
- is('для английского: Google, тип 3, регион 84', state.calls.filter((c) => c.method === 'edit/keywords_2/volumes/go').pop().body.qualifiers, [{ region_key: 84, searcher_key: 1, type: 3 }]);
181
- ok('поле частоты названо по определителю', state.calls.filter((c) => c.method === 'get/keywords_2/keywords').pop().body.fields.includes('volume:84:1:3'));
182
- ok('без ключей источник «none», а не ошибка', (await volumesViaTopvisor(['x y'], { client: null })).source === 'none');
183
- server.close();
184
- }
185
-
186
- // ---- вся карта с подделками и вывод
187
- {
188
- const { state, server, base } = await fakeTopvisor();
189
- const client = topvisorClient({ userId: '5', key: 'secret', base });
190
- const files = {
191
- 'https://x.ru/': '<html><head><title>Квартиры у моря в Сочи | Юг-Дом</title></head><body><h1>Квартиры у моря в Сочи</h1><nav><a href="/">Главная</a><a href="/doma/">Дома в Сочи</a></nav></body></html>',
192
- 'https://x.ru/robots.txt': 'Sitemap: https://x.ru/sitemap.xml',
193
- 'https://x.ru/sitemap.xml': '<urlset><url><loc>https://x.ru/kvartiry-u-morya-v-sochi/</loc></url><url><loc>https://x.ru/doma/</loc></url></urlset>',
194
- };
195
- const fetchJson = async (u) => { const q = decodeURIComponent((u.match(/[?&](?:q|part)=([^&]*)/) || [])[1] || ''); const b = q.replace(/^(как|где|что такое|сколько стоит)\s+/, '').replace(/\s+(цена|отзывы|или)$/, ''); return [q, [`${b} недорого`, `${b} вторичка`]]; };
196
- const d = await buildDemandMap('https://x.ru/', { lang: 'ru', client, fetchText: async (u) => { if (!files[u]) throw new Error('404'); return files[u]; }, fetchJson, delayMs: 0, pollMs: 1, max: 20, engines: ['yandex'] });
197
- ok('хозяин и регион названы', d.host === 'x.ru' && d.region === 225 && d.regionName === 'Россия');
198
- ok('зёрна без бренда', d.seeds.includes('квартиры у моря в сочи') && !d.seeds.some((s) => /юг-дом/.test(s)));
199
- ok('частоты собраны на все запросы', d.volumes.source === 'Topvisor' && d.totals.withVolume === d.totals.queries && d.totals.queries > 0);
200
- ok('отсортировано по частоте', d.queries.every((q, i) => i === 0 || (d.queries[i - 1].volume ?? -1) >= (q.volume ?? -1)));
201
- ok('итоги сходятся', d.totals.covered + d.totals.uncovered === d.totals.queries && d.totals.volumeCovered + d.totals.volumeUncovered === d.totals.volumeTotal);
202
- ok('есть и покрытые, и непокрытые', d.totals.covered > 0 && d.totals.uncovered > 0);
203
- ok('в JSON нет undefined', !/undefined/.test(JSON.stringify(d)));
204
- const html = renderDemandHtml(d);
205
- const md = renderDemandMarkdown(d);
206
- ok('в выводе нет null, undefined, NaN', !/null|undefined|NaN/.test(html + md));
207
- ok('вывод называет долю спроса без страницы', /% спроса ниши/.test(html) && /% спроса ниши/.test(md));
208
- ok('источник назван и сказано, где проверить бесплатно', /Topvisor/.test(html) && /wordstat\.yandex\.ru/.test(html) && /в баллы отчёта они не входят/.test(html));
209
- ok('таблица содержит запросы и адреса', /<table>/.test(html) && /\/kvartiry-u-morya-v-sochi\//.test(html));
210
- const enHtml = renderDemandHtml({ ...d, lang: 'en', regionName: 'Russia' }, { lang: 'en' });
211
- ok('английский вывод без кириллицы вне запросов', !/[А-Яа-яЁё]/.test(enHtml.replace(/<td>[^<]*<\/td>/g, '')));
212
- const row = demandSourceRow(d);
213
- ok('строка источников: платно и не в балл', /Платный/.test(row.access) && /не входит/.test(row.access));
214
- const csv = demandCsv(d).split('\n');
215
- ok('csv: шапка плюс строка на запрос', csv.length === d.queries.length + 1 && csv[0] === '"query","volume","has_page","url"');
216
- const off = await buildDemandMap('https://x.ru/', { lang: 'ru', volumes: false, fetchText: async (u) => files[u] || '', fetchJson, delayMs: 0, max: 5, engines: ['yandex'] });
217
- ok('без частот доля считается по числу запросов и так и сказано', off.volumes.source === 'none' && /по числу запросов/.test(renderDemandHtml(off)) && /% найденных вопросов/.test(renderDemandHtml(off)));
218
- ok('запрос без частоты подписан словами, а не нулём', /нет данных/.test(renderDemandHtml(off)) && !/<td class="num">0<\/td>/.test(renderDemandHtml(off)));
219
- const empty = renderDemandHtml({ host: 'e.ru', lang: 'ru', region: 225, regionName: 'Россия', seeds: [], discovery: { engines: ['yandex'] }, volumes: { source: 'none' }, queries: [], totals: demandTotals([]) });
220
- ok('пустая карта объяснена словами', /ни одного запроса/.test(empty) && !/NaN/.test(empty));
221
- is('регионов по умолчанию хватает на оба языка', [REGIONS[225].engine, REGIONS[84].engine], ['yandex', 'google']);
222
- server.close();
223
- }
224
-
225
- // ---- отчёт: карта печатается приложением, входит в таблицу источников и не трогает баллы
226
- {
227
- const sample = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
228
- const before = JSON.stringify(computeScores(sample.checks || []).scores);
229
- const fixture = { host: 'example-villas.example', lang: 'en', region: 84, regionName: 'United States', seeds: ['villas phuket'], discovery: { calls: 8, failed: 0, engines: ['google'] }, pagesIndexed: 3, volumes: { source: 'Topvisor', engine: 'google', type: 3, price: 1.2, currency: 'RUB', filled: 2, asked: 2, note: '', projectId: 1, groupId: 2 }, queries: [{ q: 'villas phuket for sale', volume: 880, covered: true, url: 'https://www.example-villas.example/villas/' }, { q: 'phuket villa rental', volume: 590, covered: false, url: null }] };
230
- fixture.totals = demandTotals(fixture.queries);
231
- const withMap = { ...sample, demand: fixture };
232
- const html = toHtml(withMap);
233
- ok('в отчёте появилась страница «NN · Demand»', /\d{2} · Demand/.test(html) && /Demand map/.test(html));
234
- ok('таблица источников называет Topvisor платным и не входящим в балл', /Topvisor \(volumes for the demand map\)/.test(html) && /counts towards no score/.test(html));
235
- ok('без карты страницы нет', !/\d{2} · Demand/.test(toHtml(sample)));
236
- is('баллы с картой и без неё одинаковы', JSON.stringify(computeScores(withMap.checks || []).scores), before);
237
- ok('проверка баллов проходит с картой', verifyScores(withMap).ok !== false);
238
- const ruHtml = toHtml({ ...sample, meta: { ...sample.meta, lang: 'ru' }, demand: { ...fixture, lang: 'ru', regionName: 'США' } });
239
- ok('русский отчёт: «NN · Спрос»', /\d{2} · Спрос/.test(ruHtml) && /Карта спроса/.test(ruHtml));
240
- }
241
-
242
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
243
- console.log('\nкарта спроса: зёрна, подсказки, частоты, покрытие и вывод в порядке');
@@ -1,42 +0,0 @@
1
- /**
2
- * Эталон: движок прогоняется по записанным ответам пяти сайтов и сверяется с ответами, проверенными
3
- * вручную (test/etalon/*-truth.json). Одно расхождение, и версия не выкладывается.
4
- */
5
- import { readdirSync, readFileSync, existsSync } from 'node:fs';
6
- import { replayer } from './etalon.mjs';
7
- import { collect } from './collect.mjs';
8
- import { secondLook } from './verify.mjs';
9
- import { localiseChecks } from './i18n.mjs';
10
-
11
- const dir = new URL('../test/etalon/', import.meta.url);
12
- let fail = 0; let pass = 0;
13
- const real = globalThis.fetch;
14
- for (const f of readdirSync(dir).filter((x) => x.endsWith('-truth.json')).sort()) {
15
- const truth = JSON.parse(readFileSync(new URL(f, dir), 'utf8'));
16
- const name = f.replace(/-truth\.json$/, '');
17
- const rec = new URL(`${name}.json.gz`, dir);
18
- if (!existsSync(rec)) { console.log(`FAIL ${name}: нет записи ответов сайта`); fail++; continue; }
19
- globalThis.fetch = replayer(rec);
20
- const raw = await collect(truth.site, { pages: 20, tier: '9', lang: truth.lang, rendered: false, log: () => {}, pageDelayMs: 0 });
21
- await secondLook(raw, { maxPages: 120, probes: false, log: () => {} });
22
- globalThis.fetch = real;
23
- const by = Object.fromEntries(raw.checks.map((c) => [c.id, c]));
24
- // Число по выборке без слова «выборки» читается как число по всему сайту и преуменьшает проблему (14.10.2026).
25
- const sampleN = (raw.sample || []).filter((p) => p && p.title !== undefined).length;
26
- const shown = truth.lang === 'ru' ? localiseChecks(raw.checks, 'ru') : raw.checks;
27
- for (const c of shown) {
28
- const v = String(c.value || '');
29
- const re = truth.lang === 'ru' ? /из (\d+) (?:страниц\S*|проверенн\S*)(?!\s+(?:выборки|карты|сайта|прочитанн))/gu : /of (\d+) (?!sampled|pages read|pages in the sitemap)(?:pages?)\b/g;
30
- for (const m of v.matchAll(re)) if (Number(m[1]) === sampleN) { fail++; console.log(`FAIL ${name} ${c.id}: число по выборке без слова «выборки»: «${v}»`); }
31
- }
32
- if (truth.niche && raw.niche?.niche !== truth.niche) { console.log(`FAIL ${name}: ниша ${raw.niche?.niche}, правильно ${truth.niche}`); fail++; } else pass++;
33
- for (const [id, exp] of Object.entries(truth.checks || {})) {
34
- const c = by[id];
35
- const okStatus = !exp.status || (c && c.status === exp.status);
36
- const okValue = !exp.valueRegex || (c && new RegExp(String(exp.valueRegex).replace(/\\w/g, '[\\p{L}\\p{N}_]'), 'iu').test(String(c.value)));
37
- if (okStatus && okValue) pass++;
38
- else { fail++; console.log(`FAIL ${name} ${id}: движок ${c ? `${c.status} «${c.value}»` : 'нет строки'}, ждали ${exp.status || ''} ${exp.valueRegex ? `/${exp.valueRegex}/` : ''}`); }
39
- }
40
- }
41
- console.log(fail ? `эталон: ${fail} расхождений, ${pass} совпадений` : `эталон: всё сходится (${pass} проверок)`);
42
- if (fail) process.exit(1);