@operstack/audit 0.36.2 → 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.
@@ -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);
package/src/test-fix.mjs DELETED
@@ -1,115 +0,0 @@
1
- #!/usr/bin/env node
2
- /** Пакет Fix: раскладка проверок, доля возврата, отчёт о закрытии, два языка. */
3
- import { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
4
- import { readFileSync } from 'node:fs';
5
-
6
- let bad = 0;
7
- 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}`); };
8
- const ok = (n, c) => is(n, Boolean(c), true);
9
-
10
- const audit = (checks) => ({ meta: { host: 'x.ru', collectedAt: '2026-09-11T00:00:00.000Z' }, checks });
11
- const c = (id, status, value = 'текст') => ({ id, status, value, label: id, group: 'technical' });
12
-
13
- const many = audit([
14
- c('robots', 'bad'), c('sitemap', 'warn'), c('llms', 'bad'), c('og', 'warn'), c('canonical', 'warn'),
15
- c('trust-entity', 'warn'), c('conv-messenger', 'warn'),
16
- c('answer-first', 'warn'), c('sources', 'bad'),
17
- c('conv-form-depth', 'bad'),
18
- c('links-profile', 'warn'),
19
- c('https', 'ok'), c('viewport', 'ok'),
20
- ]);
21
-
22
- const plan = buildFixPlan(many, { platform: 'files', price: 21000, currency: 'RUB' });
23
- is('закрываем восемь', plan.included.length, 8);
24
- is('фактов клиента требуют две', plan.needsClient.length, 2);
25
- is('не обещаем одну', plan.notPossible.length, 1);
26
- is('пройденные проверки в список не попадают', plan.included.some((x) => x.check.id === 'https'), false);
27
- is('доля считается от числа включённых', plan.share, Math.round((21000 / 8) * 100) / 100);
28
- ok('список не тонкий', !plan.thin);
29
- ok('у каждой включённой проверки есть действие', plan.included.every((x) => typeof x.action === 'string' && x.action.length > 10));
30
- ok('платные данные по ссылкам не обещаются', plan.notPossible.some((x) => x.check.id === 'links-profile'));
31
-
32
- const cms = buildFixPlan(many, { platform: 'cms', price: 21000, currency: 'RUB' });
33
- ok('на готовой CMS работа с исходниками не обещается', cms.notPossible.some((x) => x.check.id === 'conv-form-depth'));
34
- is('и в список она не попадает', cms.included.some((x) => x.check.id === 'conv-form-depth'), false);
35
- ok('доля на CMS выше, потому что работ меньше', cms.share > plan.share);
36
-
37
- const thin = buildFixPlan(audit([c('og', 'warn'), c('canonical', 'warn')]), { price: 21000, currency: 'RUB' });
38
- ok('тонкий список помечен', thin.thin);
39
- ok('письмо про тонкий список говорит прямо', renderFixPlan(thin).includes('закрывать у вас почти нечего'));
40
-
41
- const empty = buildFixPlan(audit([c('https', 'ok')]), { price: 21000 });
42
- is('пустой список', empty.included.length, 0);
43
- is('доля при пустом списке ноль', empty.share, 0);
44
- ok('письмо говорит, что покупать нечего', renderFixPlan(empty).includes('Покупать нечего'));
45
-
46
- const letter = renderFixPlan(plan);
47
- ok('в письме есть цена и доля', letter.includes('21000 RUB') && letter.includes(String(plan.share)));
48
- ok('в письме названо, что не входит', letter.includes('нужны ваши факты'));
49
- ok('в чек-листе есть все включённые', plan.included.every((x) => renderFixChecklist(plan).includes(x.check.label)));
50
-
51
- // Язык. Покупка за доллары не должна приходить письмом по-русски. Названия и значения проверок
52
- // приходят из сборщика уже на языке аудита, поэтому и фикстура здесь английская.
53
- const ce = (id, status, value = 'plain text') => ({ id, status, value, label: id, group: 'technical' });
54
- const manyEn = { meta: { host: 'x.com', collectedAt: '2026-09-11T00:00:00.000Z' }, checks: many.checks.map((x) => ce(x.id, x.status)) };
55
- const en = buildFixPlan(manyEn, { platform: 'files', price: 249, lang: 'en' });
56
- is('валюта по умолчанию для английского', en.currency, 'USD');
57
- is('валюта по умолчанию для русского', buildFixPlan(many, { price: 21000 }).currency, 'RUB');
58
- is('раскладка от языка не зависит', [en.included.length, en.needsClient.length, en.notPossible.length], [8, 2, 1]);
59
- const letterEn = renderFixPlan(en);
60
- ok('английское письмо без кириллицы', !/[А-Яа-яЁё]/.test(letterEn));
61
- ok('английский чек-лист без кириллицы', !/[А-Яа-яЁё]/.test(renderFixChecklist(en)));
62
- ok('в английском письме есть цена', letterEn.includes('249 USD'));
63
- const thinEn = buildFixPlan({ meta: { host: 'x.com' }, checks: [ce('og', 'warn')] }, { price: 249, lang: 'en' });
64
- ok('английское предупреждение о тонком списке', renderFixPlan(thinEn).includes('little here to fix'));
65
- ok('единственное число по-английски', renderFixPlan(thinEn).includes('1 check at'));
66
-
67
- const after = audit([
68
- c('robots', 'ok', 'починено'), c('sitemap', 'ok', 'починено'), c('llms', 'ok', 'починено'),
69
- c('og', 'ok', 'починено'), c('canonical', 'ok', 'починено'), c('trust-entity', 'ok', 'починено'),
70
- c('conv-messenger', 'warn', 'так и не сделали'), c('conv-form-depth', 'ok', 'починено'),
71
- ]);
72
- const rep = buildFixReport(plan, after);
73
- is('закрыто семь', rep.closed.length, 7);
74
- is('осталось одно', rep.stillOpen.length, 1);
75
- is('возврат равен доле одной проверки', rep.refund, plan.share);
76
- is('незакрытой осталась именно та проверка', rep.stillOpen[0].check.id, 'conv-messenger');
77
- ok('в отчёте она названа', renderFixReport(rep).includes(rep.stillOpen[0].check.label));
78
- ok('в отчёте есть сумма возврата', renderFixReport(rep).includes(String(rep.refund)));
79
- ok('английский отчёт без кириллицы', !/[А-Яа-яЁё]/.test(renderFixReport(buildFixReport(en, { checks: [] }))));
80
-
81
- const repAll = buildFixReport(buildFixPlan(audit([c('robots', 'bad')]), { price: 249 }), audit([c('robots', 'ok')]));
82
- is('всё закрыто, возврата нет', repAll.refund, 0);
83
- ok('отчёт так и говорит', renderFixReport(repAll).includes('возвращать нечего'));
84
-
85
- // пропавшая из прогона проверка считается незакрытой, а не закрытой молча
86
- const gone = buildFixReport(buildFixPlan(audit([c('robots', 'bad'), c('og', 'warn')]), { price: 100 }), audit([c('robots', 'ok')]));
87
- is('пропавшая проверка не закрыта', gone.stillOpen.length, 1);
88
- is('и за неё возвращается доля', gone.refund, 50);
89
-
90
- // Язык аудита. Русское письмо переводит названия проверок само, английское по русскому аудиту не собрать.
91
- const enAudit = { meta: { host: 'x.com', lang: 'en', collectedAt: '2026-09-11T00:00:00.000Z' }, checks: [{ id: 'www', status: 'warn', label: 'www canonicalisation', value: 'www.x.com answers 200 without redirecting', group: 'technical' }] };
92
- const ruLetter = buildFixPlan(enAudit, { price: 21000 });
93
- ok('русское письмо по английскому аудиту переводит названия', /[А-Яа-я]/.test(ruLetter.included[0].check.label));
94
- ok('и это не считается расхождением языков', !ruLetter.langMismatch);
95
- const ruAudit = { meta: { host: 'x.ru', lang: 'ru', collectedAt: '2026-09-11T00:00:00.000Z' }, checks: [c('www', 'warn')] };
96
- ok('английское письмо по русскому аудиту помечено как брак', buildFixPlan(ruAudit, { lang: 'en', price: 249 }).langMismatch);
97
-
98
- // у каждого действия есть оба языка
99
- const noEn = Object.entries(FIX_ACTIONS).filter(([, r]) => {
100
- const f = r.action || r.why;
101
- return !f || typeof f !== 'object' || !f.ru || !f.en;
102
- }).map(([id]) => id);
103
- is('у каждого действия есть русский и английский текст', noEn, []);
104
-
105
- // покрытие: у каждой проверки, которую умеет собирать сборщик, есть решение
106
- const files = process.argv.slice(2);
107
- if (files.length) {
108
- const ids = new Set();
109
- for (const f of files) for (const x of JSON.parse(readFileSync(f, 'utf8')).checks) ids.add(x.id);
110
- const missing = [...ids].filter((id) => !FIX_ACTIONS[id]);
111
- is('для каждой собираемой проверки описано решение', missing, []);
112
- }
113
-
114
- if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
115
- console.log('\nвсе тесты пакета Fix прошли');
@@ -1,81 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Один сбой сети не должен менять балл.
4
- *
5
- * 15.09.2026 сайт avtokomissar-app.ru получил 52 на русской площадке и 67 на английской с
6
- * разницей в две минуты. Движок, его версия и параметры у обеих одинаковые, значит дело было в
7
- * самом прогоне: служебные файлы читались по одному разу, и один неудачный ответ решал судьбу
8
- * целой области. robots.txt стоит 25 очков, llms.txt 15. Владелец сайта читает такое расхождение
9
- * как «вы врёте», и он прав.
10
- *
11
- * Здесь сервер отдаёт llms.txt отказом только на первый запрос, а со второго нормально. Балл
12
- * обязан совпасть с тем, что даёт всегда исправный сервер.
13
- *
14
- * Сервер поднимается на loopback, а зовётся по имени lvh.me: оно публично указывает на 127.0.0.1,
15
- * и так проверка проходит свой же запрет на частные адреса честно, без лазейки в коде продукта.
16
- */
17
- import { createServer } from 'node:http';
18
- import { lookup } from 'node:dns/promises';
19
- import { checkVisibility } from './visibility.mjs';
20
-
21
- let bad = 0;
22
- const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
23
- const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
24
-
25
- const PAGE = `<!doctype html><html lang="ru"><head><title>Пример</title><meta name="description" content="Описание"></head>
26
- <body><main><h1>Пример сайта</h1>${'<p>Обычный абзац текста про товар и услугу, чтобы было что цитировать. </p>'.repeat(40)}</main></body></html>`;
27
- const LLMS = `# Пример\n\n> Короткое описание сайта.\n\n## Разделы\n\n${Array.from({ length: 6 }, (_, i) => `- [Страница ${i}](http://HOST/p${i}.html): о чём она`).join('\n')}\n`;
28
-
29
- /** @param failFirst сколько первых запросов к llms.txt отдать отказом */
30
- const serve = (failFirst) => new Promise((resolve) => {
31
- let asked = 0;
32
- const s = createServer((req, res) => {
33
- if (req.url.startsWith('/llms.txt')) {
34
- asked += 1;
35
- if (asked <= failFirst) { res.writeHead(503, { 'content-type': 'text/html' }); res.end('<html>занято</html>'); return; }
36
- res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
37
- res.end(LLMS.replaceAll('HOST', req.headers.host));
38
- return;
39
- }
40
- if (req.url.startsWith('/robots.txt')) { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('User-agent: *\nAllow: /\n'); return; }
41
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(PAGE);
42
- });
43
- s.listen(0, '127.0.0.1', () => resolve({ s, url: `http://lvh.me:${s.address().port}/`, asked: () => asked }));
44
- });
45
-
46
- let dnsOk = true;
47
- try { const a = await lookup('lvh.me'); dnsOk = a.address === '127.0.0.1'; } catch { dnsOk = false; }
48
-
49
- if (!dnsOk) {
50
- console.log('ПРОПУЩЕНО: lvh.me не указывает на 127.0.0.1 с этой машины');
51
- } else {
52
- const opts = { budgetMs: 9000, samplePages: 3, lang: 'ru' };
53
-
54
- const good = await serve(0);
55
- let clean;
56
- try { clean = await checkVisibility(good.url, opts); } finally { good.s.close(); }
57
- ok('исправный сервер: проверка прошла', clean.ok === true);
58
- const cleanIndex = clean.areas.find((a) => a.id === 'index');
59
- ok('и карта для агентов зачтена', cleanIndex.score > 0);
60
-
61
- const flaky = await serve(1);
62
- let shaky;
63
- try { shaky = await checkVisibility(flaky.url, opts); } finally { flaky.s.close(); }
64
- ok('сервер с одним сбоем: проверка прошла', shaky.ok === true);
65
- ok('к llms.txt обратились дважды, а не один раз', flaky.asked() >= 2);
66
- const shakyIndex = shaky.areas.find((a) => a.id === 'index');
67
- is('область карты дала тот же балл', shakyIndex.score, cleanIndex.score);
68
- is('и общий балл совпал', shaky.score, clean.score);
69
-
70
- // Постоянный отказ это не сбой сети: область честно остаётся неизмеренной.
71
- const dead = await serve(99);
72
- let never;
73
- try { never = await checkVisibility(dead.url, opts); } finally { dead.s.close(); }
74
- const neverIndex = never.areas.find((a) => a.id === 'index');
75
- is('при постоянном отказе область не измерена', neverIndex.measured, false);
76
- is('и балла у неё нет, а не ноль', neverIndex.score, null);
77
- ok('и это не выдаётся за «у вас нет llms.txt»', !neverIndex.findings.some((f) => /Нет llms\.txt/.test(f.text)));
78
- }
79
-
80
- console.log(bad ? `\nПРОВАЛЕНО: ${bad}` : '\nодин сбой сети балл не меняет, постоянный отказ не считается ни в плюс, ни в минус');
81
- process.exit(bad ? 1 : 0);