@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.
- package/README.md +10 -0
- package/bin/audit.mjs +2 -2
- package/package.json +5 -3
- package/src/client-eye.mjs +389 -0
- package/src/collect.mjs +171 -85
- package/src/fix.mjs +124 -5
- package/src/foundation.mjs +2 -1
- package/src/i18n.mjs +50 -25
- package/src/index.mjs +2 -1
- package/src/narrative.mjs +56 -25
- package/src/niche.mjs +13 -12
- package/src/pagekind.mjs +18 -1
- package/src/presentation.mjs +3 -2
- package/src/prompts.mjs +163 -42
- package/src/render.mjs +61 -30
- package/src/rendered.mjs +6 -2
- package/src/tasks-pdf.mjs +10 -5
- package/src/verify.mjs +26 -13
- package/src/visibility.mjs +137 -45
- package/src/vy.mjs +13 -0
- package/src/test-agency-ids.mjs +0 -48
- package/src/test-agency.mjs +0 -158
- package/src/test-agent-surface.mjs +0 -46
- package/src/test-answer-first.mjs +0 -27
- package/src/test-blocked.mjs +0 -100
- package/src/test-crawl-links.mjs +0 -87
- package/src/test-demand.mjs +0 -243
- package/src/test-etalon.mjs +0 -42
- package/src/test-fix.mjs +0 -115
- package/src/test-flaky-files.mjs +0 -81
- package/src/test-foundation.mjs +0 -126
- package/src/test-handover.mjs +0 -148
- package/src/test-i18n.mjs +0 -68
- package/src/test-narrative.mjs +0 -117
- package/src/test-overall.mjs +0 -236
- package/src/test-planted.mjs +0 -114
- package/src/test-presentation.mjs +0 -67
- package/src/test-prompts.mjs +0 -142
- package/src/test-prospect-count.mjs +0 -12
- package/src/test-redirect-host.mjs +0 -63
- package/src/test-rendered.mjs +0 -136
- package/src/test-report-wording.mjs +0 -35
- package/src/test-ru-report.mjs +0 -95
- package/src/test-ru-sources.mjs +0 -41
- package/src/test-sample-fairness.mjs +0 -50
- package/src/test-sample.mjs +0 -91
- package/src/test-scores.mjs +0 -75
- package/src/test-shopify-store.mjs +0 -159
- package/src/test-sitemap.mjs +0 -91
- package/src/test-unmeasured.mjs +0 -173
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 прошли');
|
package/src/test-flaky-files.mjs
DELETED
|
@@ -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);
|
package/src/test-foundation.mjs
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/** Пакет Foundation: раскладка страниц, доли работы, цена, два языка. */
|
|
3
|
-
import { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from './foundation.mjs';
|
|
4
|
-
|
|
5
|
-
let bad = 0;
|
|
6
|
-
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}`); };
|
|
7
|
-
const ok = (n, c) => is(n, Boolean(c), true);
|
|
8
|
-
|
|
9
|
-
const page = (over) => ({ url: 'https://x.ru/p', title: 'Страница', words: 900, answerFirst: true, sourcePhrases: 2, citedParagraphs: 1, figureParagraphs: 4, h2Count: 5, tables: 1, ...over });
|
|
10
|
-
const audit = (sample) => ({ meta: { host: 'x.ru', collectedAt: '2026-09-11T00:00:00.000Z' }, sample });
|
|
11
|
-
|
|
12
|
-
const clean = buildFoundationScope(audit([page(), page({ url: 'https://x.ru/b' })]));
|
|
13
|
-
is('чистые страницы в смету не попадают', clean.pages.length, 0);
|
|
14
|
-
is('и считаются как в порядке', clean.cleanPages, 2);
|
|
15
|
-
ok('письмо говорит, что работы нет', renderFoundationScope(clean).includes('Работы нет'));
|
|
16
|
-
is('цена без работы ноль долей', clean.shares, 0);
|
|
17
|
-
|
|
18
|
-
const thin = buildFoundationScope(audit([page({ words: 120 })]));
|
|
19
|
-
is('пустая страница это три доли', thin.pages[0].shares, 3);
|
|
20
|
-
is('и одна работа, а не список повторов', buildFoundationScope(audit([page({ words: 90, answerFirst: false, h2Count: 0, tables: 0 })])).pages[0].issues.map((i) => i.id), ['thin']);
|
|
21
|
-
is('и помечена как «написать заново»', thin.pages[0].tier, 'rewrite');
|
|
22
|
-
|
|
23
|
-
const one = buildFoundationScope(audit([page({ answerFirst: false })]));
|
|
24
|
-
is('одна беда это одна доля', one.pages[0].shares, 1);
|
|
25
|
-
is('и это «одна правка»', one.pages[0].tier, 'touch');
|
|
26
|
-
|
|
27
|
-
const two = buildFoundationScope(audit([page({ answerFirst: false, h2Count: 1 })]));
|
|
28
|
-
is('две беды это две доли', two.pages[0].shares, 2);
|
|
29
|
-
is('и обе названы', two.pages[0].issues.map((i) => i.id), ['answer-first', 'sections']);
|
|
30
|
-
|
|
31
|
-
// Пороги: короткой странице не вменяем отсутствие таблицы, пустой не вменяем всё подряд дважды
|
|
32
|
-
const short = buildFoundationScope(audit([page({ words: 400, tables: 0 })]));
|
|
33
|
-
is('таблицу спрашиваем только с длинных страниц', short.pages.length, 0);
|
|
34
|
-
const noSrc = buildFoundationScope(audit([page({ words: 250, sourcePhrases: 0, citedParagraphs: 0, h2Count: 0 })]));
|
|
35
|
-
is('с пустой страницы не спрашиваем источники и разделы отдельно', noSrc.pages[0].issues.map((i) => i.id), ['thin']);
|
|
36
|
-
|
|
37
|
-
// Цена
|
|
38
|
-
const many = buildFoundationScope(audit([page({ words: 100 }), page({ url: 'https://x.ru/b', answerFirst: false, h2Count: 1 }), page({ url: 'https://x.ru/c', answerFirst: false })]));
|
|
39
|
-
is('доли складываются', many.shares, 6);
|
|
40
|
-
is('цена это доли на цену доли', many.raw, 6 * 4200);
|
|
41
|
-
is('но не ниже минимума', many.price, Math.max(42000, 6 * 4200));
|
|
42
|
-
ok('минимум сработал', many.atMinimum === (many.price > many.raw));
|
|
43
|
-
const big = buildFoundationScope(audit(Array.from({ length: 12 }, (_, i) => page({ url: `https://x.ru/${i}`, words: 100 }))));
|
|
44
|
-
is('на большом объёме цена выше минимума', big.price, 36 * 4200);
|
|
45
|
-
ok('и это уже не минимум', !big.atMinimum);
|
|
46
|
-
|
|
47
|
-
const withNew = buildFoundationScope(audit([page({ answerFirst: false })]), { newPages: 2 });
|
|
48
|
-
is('новая страница считается как написанная с нуля', withNew.newShares, 6);
|
|
49
|
-
is('и входит в общую сумму долей', withNew.shares, 7);
|
|
50
|
-
ok('в письме названы новые страницы', renderFoundationScope(withNew).includes('Новые страницы: 2'));
|
|
51
|
-
|
|
52
|
-
// Порядок: тяжёлое сверху
|
|
53
|
-
const order = buildFoundationScope(audit([page({ url: 'https://x.ru/a', answerFirst: false }), page({ url: 'https://x.ru/b', words: 100 })]));
|
|
54
|
-
is('самая тяжёлая страница идёт первой', order.pages[0].url, 'https://x.ru/b');
|
|
55
|
-
|
|
56
|
-
// Search Console: без доступа говорим об этом прямо
|
|
57
|
-
const noGsc = renderFoundationScope(many);
|
|
58
|
-
ok('без доступа к Search Console письмо это признаёт', noGsc.includes('Доступа к Search Console у нас нет'));
|
|
59
|
-
ok('и говорит, что доступ бесплатный', noGsc.includes('бесплатно'));
|
|
60
|
-
const yesGsc = renderFoundationScope(buildFoundationScope(audit([page({ answerFirst: false })]), { hasSearchConsole: true }));
|
|
61
|
-
ok('с доступом письмо берёт страницы с показами', yesGsc.includes('уже собирают показы'));
|
|
62
|
-
|
|
63
|
-
// Язык
|
|
64
|
-
const en = buildFoundationScope(audit([page({ words: 100 }), page({ url: 'https://x.ru/b', answerFirst: false })]), { lang: 'en' });
|
|
65
|
-
is('валюта английской сметы', en.currency, 'USD');
|
|
66
|
-
is('цена доли по умолчанию', en.unit, 50);
|
|
67
|
-
is('минимум пакета', en.minimum, 500);
|
|
68
|
-
const letterEn = renderFoundationScope({ ...en, host: 'x.com', pages: en.pages.map((p) => ({ ...p, title: 'A page' })) });
|
|
69
|
-
ok('английское письмо без кириллицы', !/[А-Яа-яЁё]/.test(letterEn));
|
|
70
|
-
|
|
71
|
-
// Деньги в письме только по флагу: цену называет человек, а не машина
|
|
72
|
-
ok('по умолчанию цены в письме нет', !/USD|RUB/.test(letterEn));
|
|
73
|
-
ok('вместо неё объём работы', letterEn.includes('share') && letterEn.includes('name the price separately'));
|
|
74
|
-
const priced = buildFoundationScope(audit([page({ url: 'https://x.ru/a/', words: 100 })]), { lang: 'en', withPrice: true });
|
|
75
|
-
ok('с флагом цена появляется', renderFoundationScope(priced).includes('500 USD'));
|
|
76
|
-
ok('и в чек-листе тоже', renderFoundationChecklist(priced).includes('500 USD'));
|
|
77
|
-
ok('без флага чек-лист без денег', !/USD|RUB/.test(renderFoundationChecklist(en)));
|
|
78
|
-
is('доли считаются в обоих случаях', [en.shares > 0, priced.shares > 0], [true, true]);
|
|
79
|
-
|
|
80
|
-
// Короткие страницы бывают короткими намеренно, поэтому их просим подтвердить
|
|
81
|
-
const thinLetter = renderFoundationScope(buildFoundationScope(audit([page({ url: 'https://x.ru/n/', words: 90 })])));
|
|
82
|
-
ok('письмо просит подтвердить короткие страницы', thinLetter.includes('задуманы короткими'));
|
|
83
|
-
ok('без коротких страниц такой оговорки нет', !renderFoundationScope(buildFoundationScope(audit([page({ url: 'https://x.ru/a/', answerFirst: false })]))).includes('задуманы короткими'));
|
|
84
|
-
|
|
85
|
-
// Чек-лист
|
|
86
|
-
const list = renderFoundationChecklist(many);
|
|
87
|
-
ok('в чек-листе все страницы сметы', many.pages.every((p) => list.includes(p.url)));
|
|
88
|
-
ok('и все работы по ним', many.pages.every((p) => p.issues.every((i) => list.includes(i.id))));
|
|
89
|
-
|
|
90
|
-
// Страницы без разбора (редиректы, ошибки) в смету не лезут
|
|
91
|
-
const junk = buildFoundationScope(audit([{ url: 'https://x.ru/r', redirect: 'https://x.ru/', status: 301 }, { url: 'https://x.ru/e', status: 404 }, page({ answerFirst: false })]));
|
|
92
|
-
is('нечитаемые страницы пропускаются', junk.sampledPages, 1);
|
|
93
|
-
is('и не превращаются в работу', junk.pages.length, 1);
|
|
94
|
-
|
|
95
|
-
// Служебные страницы: политику и контакты в работу не берём и в счёт не ставим
|
|
96
|
-
const util = buildFoundationScope(audit([
|
|
97
|
-
page({ url: 'https://x.ru/privacy/', words: 1100, answerFirst: false, sourcePhrases: 0, citedParagraphs: 0, tables: 0 }),
|
|
98
|
-
page({ url: 'https://x.ru/terms/', words: 1100, answerFirst: false, sourcePhrases: 0, citedParagraphs: 0, tables: 0 }),
|
|
99
|
-
page({ url: 'https://x.ru/contact/', words: 1100, answerFirst: false, h2Count: 1 }),
|
|
100
|
-
page({ url: 'https://x.ru/guides/kak-vybrat/', answerFirst: false }),
|
|
101
|
-
]));
|
|
102
|
-
is('служебные страницы в счёт не идут', util.pages.length, 1);
|
|
103
|
-
is('и посчитаны отдельно', util.utilityPages, 3);
|
|
104
|
-
is('содержательных страниц одна', util.contentPages, 1);
|
|
105
|
-
ok('письмо объясняет, почему служебные не в счёте', renderFoundationScope(util).includes('в работу не берём и в счёт не ставим'));
|
|
106
|
-
ok('в счёте осталась только содержательная', util.pages[0].url.includes('/guides/'));
|
|
107
|
-
|
|
108
|
-
// Источники спрашиваем только там, где цифры есть
|
|
109
|
-
const noFigures = buildFoundationScope(audit([page({ url: 'https://x.ru/o-nas/', figureParagraphs: 0, sourcePhrases: 0, citedParagraphs: 0 })]));
|
|
110
|
-
is('страница без цифр не должна источников', noFigures.pages.length, 0);
|
|
111
|
-
const withFigures = buildFoundationScope(audit([page({ url: 'https://x.ru/o-nas/', figureParagraphs: 3, sourcePhrases: 0, citedParagraphs: 0 })]));
|
|
112
|
-
is('страница с цифрами без источников это работа', withFigures.pages[0].issues.map((i) => i.id), ['sources']);
|
|
113
|
-
|
|
114
|
-
// Четыре беды это уже работа заново, а не «переработка» по цене двух долей
|
|
115
|
-
const four = buildFoundationScope(audit([page({ url: 'https://x.ru/g/', words: 700, answerFirst: false, sourcePhrases: 0, citedParagraphs: 0, h2Count: 1, tables: 0 })]));
|
|
116
|
-
is('четыре беды дают четыре работы', four.pages[0].issues.length, 4);
|
|
117
|
-
is('и три доли, а не две', four.pages[0].shares, 3);
|
|
118
|
-
is('с честной пометкой', four.pages[0].tier, 'rewrite');
|
|
119
|
-
|
|
120
|
-
// Английское множественное число
|
|
121
|
-
const en1 = buildFoundationScope(audit([page({ url: 'https://x.ru/a/', answerFirst: false }), page({ url: 'https://x.ru/b/' })]), { lang: 'en' });
|
|
122
|
-
const txt1 = renderFoundationScope(en1);
|
|
123
|
-
ok('одна страница: needs work, ещё одна is fine', txt1.includes('1 page needs work, 1 more is fine'));
|
|
124
|
-
|
|
125
|
-
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
126
|
-
console.log('\nвсе тесты пакета Foundation прошли');
|
package/src/test-handover.mjs
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Готовые файлы: проверяем не «собралось ли», а что ничего не выдумано.
|
|
3
|
-
*
|
|
4
|
-
* Главный сторож здесь последний: поле, которого нет в данных, обязано остаться пропуском со
|
|
5
|
-
* словами «впишите», а не догадкой. Придуманный адрес в разметке это ложь, которую потом
|
|
6
|
-
* прочитают и поисковик, и ИИ.
|
|
7
|
-
*/
|
|
8
|
-
import assert from 'node:assert/strict';
|
|
9
|
-
import { readFileSync } from 'node:fs';
|
|
10
|
-
import { buildLlmsTxt, buildOrganisationSchema, fixTitlesAndDescriptions } from './handover.mjs';
|
|
11
|
-
|
|
12
|
-
const ok = (name, cond) => { assert.ok(cond, name); console.log('ok ', name); };
|
|
13
|
-
|
|
14
|
-
const audit = {
|
|
15
|
-
meta: { host: 'shop.ru', site: 'https://shop.ru/', lang: 'ru' },
|
|
16
|
-
entity: { profiles: ['vk.com/shop', 't.me/shop'], telephone: true, email: false },
|
|
17
|
-
sample: [
|
|
18
|
-
{ url: 'https://shop.ru/', title: 'Станки для деревообработки с гарантией и сервисом · ШопРу', description: 'Продаём и обслуживаем станки для деревообработки по всей России: сто двадцать моделей в наличии, доставка за три дня.', h1: 'Станки для дерева', firstPara: 'Мы продаём станки с 2011 года.', words: 900 },
|
|
19
|
-
{ url: 'https://shop.ru/catalog/', title: 'Каталог', description: '', h1: 'Каталог станков', firstPara: 'Сто двадцать моделей в наличии на складе в Москве, доставка по России за три дня.', words: 700 },
|
|
20
|
-
{ url: 'https://shop.ru/catalog/cnc/', title: 'Станки с ЧПУ для серийного производства мебели на заказ · ШопРу', description: 'Коротко', h1: 'Станки с ЧПУ для серийного производства мебели', firstPara: 'Станки с числовым управлением для серийного производства мебели.', words: 800 },
|
|
21
|
-
{ url: 'https://shop.ru/catalog/rare/', title: 'Очень длинный заголовок страницы, который точно не помещается в выдачу поисковика и будет обрезан', description: 'Сто двадцать моделей в наличии на складе в Москве, доставка по России за три дня и пусконаладка в цене.', h1: 'Станки', words: 800 },
|
|
22
|
-
],
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// Карта для агентов
|
|
26
|
-
{
|
|
27
|
-
const txt = buildLlmsTxt(audit, { lang: 'ru' });
|
|
28
|
-
ok('карта начинается с названия компании, а не с фразы о странице', txt.startsWith('# ШопРу\n'));
|
|
29
|
-
ok('в карте есть абзац о сайте', /^> Продаём и обслуживаем/m.test(txt));
|
|
30
|
-
ok('страницы сгруппированы в разделы', /^## catalog$/m.test(txt) && /^## Начните отсюда$/m.test(txt));
|
|
31
|
-
ok('у ссылки есть пояснение', /- \[Каталог\]\(https:\/\/shop\.ru\/catalog\/\): Сто двадцать моделей/.test(txt));
|
|
32
|
-
ok('все страницы попали в карту', (txt.match(/^- \[/gm) || []).length === 4);
|
|
33
|
-
ok('файл кончается переводом строки', txt.endsWith('\n'));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Разметка организации
|
|
37
|
-
{
|
|
38
|
-
const { json, missing, text } = buildOrganisationSchema(audit, { lang: 'ru' });
|
|
39
|
-
assert.equal(json['@type'], 'Organization');
|
|
40
|
-
assert.equal(json.name, 'ШопРу');
|
|
41
|
-
assert.equal(json.url, 'https://shop.ru/');
|
|
42
|
-
ok('профили сайта попали в sameAs', json.sameAs.length === 2 && json.sameAs.every((u) => u.startsWith('https://')));
|
|
43
|
-
ok('разметка это валидный JSON', JSON.parse(text)['@type'] === 'Organization');
|
|
44
|
-
// Вот главное: чего нет в данных, того нет и в разметке.
|
|
45
|
-
ok('адреса в разметке нет, он в списке пропусков', !('address' in json) && missing.some((m) => m.field === 'address'));
|
|
46
|
-
ok('почты нет, она в списке пропусков', !('email' in json) && missing.some((m) => m.field === 'email'));
|
|
47
|
-
ok('телефон найден на сайте, значит в пропуски не попал', !missing.some((m) => m.field === 'telephone'));
|
|
48
|
-
ok('ни одно поле не выдумано', !JSON.stringify(json).includes('ВПИСАТЬ'));
|
|
49
|
-
ok('название взято из заголовка, значит проверять его не просим', !missing.some((m) => m.field === 'name'));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Название компании стоит то в конце заголовка, то в начале: берём короткую часть
|
|
53
|
-
{
|
|
54
|
-
const name = (title, host = 'x.ru') => buildOrganisationSchema({ meta: { host }, sample: [{ url: `https://${host}/`, title }] }).json.name;
|
|
55
|
-
ok('хвост из одного слова это бренд', name('Станки для деревообработки · ШопРу') === 'ШопРу');
|
|
56
|
-
ok('начало из двух слов тоже бренд', name('MORE Group: агентство недвижимости на Пхукете') === 'MORE Group');
|
|
57
|
-
ok('через двоеточие и точку разделяется тоже', name('Проверка сайта: ваш сайт глазами ИИ · OperStack') === 'OperStack');
|
|
58
|
-
ok('служебное слово в начале бренд не перебивает', name('Главная | Акме Корп Лтд') === 'Акме Корп Лтд');
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Названия компании в заголовке главной нет: домен подставлен, но помечен на проверку
|
|
62
|
-
{
|
|
63
|
-
const noBrand = { meta: { host: 'shop.ru', site: 'https://shop.ru/' }, sample: [{ url: 'https://shop.ru/', title: 'Купить станки для деревообработки недорого', h1: 'Станки' }] };
|
|
64
|
-
const { json, missing } = buildOrganisationSchema(noBrand, { lang: 'ru' });
|
|
65
|
-
ok('фраза со страницы не выдана за название компании', json.name === 'shop.ru');
|
|
66
|
-
ok('и покупателя просят проверить название', missing.some((m) => m.field === 'name' && m.what.includes('shop.ru')));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Заголовки и описания
|
|
70
|
-
{
|
|
71
|
-
const fixes = fixTitlesAndDescriptions(audit, { lang: 'ru' });
|
|
72
|
-
const byPath = Object.fromEntries(fixes.map((f) => [f.path, f]));
|
|
73
|
-
ok('страница с длинным заголовком найдена', byPath['/catalog/cnc/'].title.problem.includes('обрежется'));
|
|
74
|
-
ok('предложенный заголовок укладывается в норму аудита', byPath['/catalog/cnc/'].title.suggestion.length >= 40 && byPath['/catalog/cnc/'].title.suggestion.length <= 60);
|
|
75
|
-
ok('заголовок собран из H1 страницы', byPath['/catalog/cnc/'].title.suggestion.startsWith('Станки с ЧПУ'));
|
|
76
|
-
ok('заголовок с многоточием не предлагается', !byPath['/catalog/cnc/'].title.suggestion.includes('\u2026'));
|
|
77
|
-
// Коротким H1 длинный заголовок не починить, и тогда правки нет, а не огрызок с многоточием.
|
|
78
|
-
ok('что нечем починить, то не предлагается', !byPath['/catalog/rare/']?.title);
|
|
79
|
-
ok('страница без описания найдена', byPath['/catalog/'].description.problem.includes('нет вовсе'));
|
|
80
|
-
ok('описание собрано из первого абзаца страницы', byPath['/catalog/'].description.suggestion.startsWith('Сто двадцать моделей'));
|
|
81
|
-
ok('рядом всегда сказано, что там сейчас', fixes.every((f) => (!f.title || 'now' in f.title) && (!f.description || 'now' in f.description)));
|
|
82
|
-
ok('главная в порядке и в список не попала', !byPath['/']);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Пустой аудит ничего не ломает и ничего не выдумывает
|
|
86
|
-
{
|
|
87
|
-
const empty = { meta: {}, sample: [] };
|
|
88
|
-
ok('карта пустого сайта не падает', typeof buildLlmsTxt(empty) === 'string');
|
|
89
|
-
ok('и честно просит вписать, чем занимается сайт', buildLlmsTxt(empty).includes('TODO'));
|
|
90
|
-
ok('разметка пустого сайта не падает', buildOrganisationSchema(empty).json['@type'] === 'Organization');
|
|
91
|
-
ok('правок у пустого сайта нет', fixTitlesAndDescriptions(empty).length === 0);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
console.log('\nготовые файлы: всё сходится');
|
|
95
|
-
|
|
96
|
-
/*
|
|
97
|
-
* Готовые файлы это то, за что платят 149. Сторож ниже следит, чтобы они не уехали в отчёт
|
|
98
|
-
* дешёвой ступени: там их печатать нельзя, иначе дорогой товар нечем отличать от дешёвого.
|
|
99
|
-
*/
|
|
100
|
-
{
|
|
101
|
-
const { toHtml } = await import('./render.mjs');
|
|
102
|
-
const base = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
103
|
-
const cheap = toHtml({ ...base, meta: { ...base.meta, tier: '29' } });
|
|
104
|
-
const paid = toHtml({ ...base, meta: { ...base.meta, tier: 'audit' } });
|
|
105
|
-
ok('в отчёте за 29 готовых файлов нет', (cheap.match(/class="page handover-page"/g) || []).length === 0);
|
|
106
|
-
ok('в аудите за 149 они есть', (paid.match(/class="page handover-page"/g) || []).length >= 2 && /"\@type": "Organization"/.test(paid));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/*
|
|
110
|
-
* Сайт нередко перечисляет в своём sameAs партнёров, застройщиков и просто домены площадок.
|
|
111
|
-
* Вставить это в разметку значит сказать поисковику, что покупатель и Sansiri одна организация.
|
|
112
|
-
*/
|
|
113
|
-
{
|
|
114
|
-
const messy = {
|
|
115
|
-
meta: { host: 'shop.ru', site: 'https://shop.ru/' },
|
|
116
|
-
entity: { profiles: ['https://www.instagram.com/shopru/', 'https://t.me/shopru'], telephone: true, email: true },
|
|
117
|
-
sample: [{ url: 'https://shop.ru/', title: 'Станки · ШопРу', description: 'Продаём станки.' }],
|
|
118
|
-
};
|
|
119
|
-
const { json } = buildOrganisationSchema(messy, { lang: 'ru' });
|
|
120
|
-
ok('профили это адреса страниц, а не домены', json.sameAs.every((u) => new URL(u).pathname.length > 1));
|
|
121
|
-
const bare = { ...messy, entity: { ...messy.entity, profiles: [] } };
|
|
122
|
-
const r = buildOrganisationSchema(bare, { lang: 'ru' });
|
|
123
|
-
ok('профилей нет, значит поля sameAs нет', !('sameAs' in r.json));
|
|
124
|
-
ok('и о пропуске сказано прямо', r.missing.some((m) => m.field === 'sameAs'));
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/*
|
|
128
|
-
* Правка обязана чинить. Заголовок, где поменялся только знак между словами, длину не чинит,
|
|
129
|
-
* а описание, оборванное на многоточии, это не готовое поле, а недоделка.
|
|
130
|
-
*/
|
|
131
|
-
{
|
|
132
|
-
const a = {
|
|
133
|
-
meta: { host: 'shop.ru', site: 'https://shop.ru/' },
|
|
134
|
-
sample: [
|
|
135
|
-
{ url: 'https://shop.ru/', title: 'Станки для деревообработки с гарантией и сервисом · ШопРу', description: 'Продаём и обслуживаем станки для деревообработки по всей России: сто двадцать моделей в наличии, доставка за три дня.' },
|
|
136
|
-
{ url: 'https://shop.ru/privacy-policy/', title: 'Политика | ШопРу', description: '', h1: 'Политика' },
|
|
137
|
-
{ url: 'https://shop.ru/cnc/', title: 'ЧПУ | ШопРу', description: '', h1: 'ЧПУ' },
|
|
138
|
-
{ url: 'https://shop.ru/dostavka/', title: 'Доставка станков по России за три дня со склада в Москве', description: 'Мы возим станки по всей России. Доставка за три дня со склада в Москве, подъём на этаж и пусконаладка входят в цену. Работаем с 2011 года и держим свой парк машин.', h1: 'Доставка', firstPara: 'Мы возим станки по всей России. Доставка за три дня со склада в Москве, подъём на этаж и пусконаладка входят в цену. Работаем с 2011 года и держим свой парк машин.' },
|
|
139
|
-
],
|
|
140
|
-
};
|
|
141
|
-
const fixes = fixTitlesAndDescriptions(a, { lang: 'ru' });
|
|
142
|
-
const byPath = Object.fromEntries(fixes.map((f) => [f.path, f]));
|
|
143
|
-
ok('служебная страница в список не попала', !byPath['/privacy-policy/']);
|
|
144
|
-
ok('перестановка знака починкой не считается', !byPath['/cnc/']);
|
|
145
|
-
const d = byPath['/dostavka/'].description;
|
|
146
|
-
ok('описание обрезано по концу предложения', d.suggestion.endsWith('.') && !d.suggestion.includes('…'));
|
|
147
|
-
ok('и укладывается в лимит', d.suggestion.length >= 70 && d.suggestion.length <= 160);
|
|
148
|
-
}
|
package/src/test-i18n.mjs
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/** Перевод проверок: покрытие, отсутствие английских хвостов, целость чисел. */
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
-
import { localiseChecks, untranslated, localiseBasisNote } from './i18n.mjs';
|
|
5
|
-
|
|
6
|
-
let bad = 0;
|
|
7
|
-
const ok = (n, c) => { if (!c) { bad++; console.error(`FAIL ${n}`); } else console.log(`ok ${n}`); };
|
|
8
|
-
const is = (n, a, b) => { const g = JSON.stringify(a) === JSON.stringify(b); if (!g) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
|
|
9
|
-
|
|
10
|
-
const files = process.argv.slice(2);
|
|
11
|
-
if (!files.length) { console.error('нужен хотя бы один audit.json для проверки покрытия'); process.exit(2); }
|
|
12
|
-
const all = [];
|
|
13
|
-
for (const f of files) all.push(...JSON.parse(readFileSync(f, 'utf8')).checks);
|
|
14
|
-
|
|
15
|
-
is('перевод есть для всех собранных проверок', untranslated(all), []);
|
|
16
|
-
|
|
17
|
-
const ru = localiseChecks(all, 'ru');
|
|
18
|
-
is('количество проверок не изменилось', ru.length, all.length);
|
|
19
|
-
ok('статусы не тронуты', ru.every((c, i) => c.status === all[i].status));
|
|
20
|
-
ok('идентификаторы не тронуты', ru.every((c, i) => c.id === all[i].id));
|
|
21
|
-
|
|
22
|
-
// в русском тексте не должно остаться английских слов, кроме технических имён
|
|
23
|
-
// Технические имена, которые обязаны остаться латиницей. Границы слова здесь не украшение:
|
|
24
|
-
// без них \bio\b съедало «io» внутри description, а Estate внутри RealEstateAgent, и тест
|
|
25
|
-
// докладывал о несуществующих английских хвостах вроде «descriptn» и «RealAgent».
|
|
26
|
-
const SCHEMA_TYPES = 'RealEstateAgent|Corporation|BreadcrumbList|WebPage|Product|Service|Place|Person|ItemList|HowTo|QAPage|NewsArticle|BlogPosting|CollectionPage|AboutPage|ContactPage|SoftwareApplication';
|
|
27
|
-
const TECH = new RegExp(`\\b(?:https?|robots\.txt|llms\.txt|sitemap|xmlrpc|HTTP|HTTPS|H1|H2|og:|twitter:|JSON-LD|FAQPage|Organization|WebSite|Article|LocalBusiness|viewport|width|initial-scale|device-width|user-scalable|maximum-scale|Disallow|User-agent|gptbot|google-extended|ccbot|anthropic-ai|claudebot|applebot-extended|facebookbot|meta-externalagent|bytespider|oai-searchbot|chatgpt-user|perplexitybot|claude-searchbot|duckassistbot|applebot|sameAs|com|ru|org|io|me|net|dev|github|npmjs|apify|producthunt|wikidata|moregroup|estate|Content|Type|Cache|Control|OperStack|SEO|AEO|GEO|AI|XML|CMS|PDF|URL|${SCHEMA_TYPES})\\b`, 'gi');
|
|
28
|
-
const leftovers = [];
|
|
29
|
-
for (const c of ru) {
|
|
30
|
-
for (const field of ['label', 'value', 'comment']) {
|
|
31
|
-
const t = String(c[field] || '')
|
|
32
|
-
.replace(/\/[A-Za-z0-9_\-/.]+\/?/g, ' ') // пути страниц клиента
|
|
33
|
-
.replace(/\b[a-z-]+:[a-z_:]+/gi, ' ') // og:title, twitter:summary_large_image
|
|
34
|
-
.replace(/\bhreflang\b/gi, ' ') // имя атрибута
|
|
35
|
-
.replace(TECH, '').replace(/[^A-Za-z]/g, ' ');
|
|
36
|
-
const words = t.split(/\s+/).filter((w) => w.length > 3);
|
|
37
|
-
if (words.length) leftovers.push(`${c.id}.${field}: ${words.join(' ')}`);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
is('английских слов в русском тексте не осталось', leftovers, []);
|
|
41
|
-
|
|
42
|
-
// числа не потерялись
|
|
43
|
-
// Английские тысячи пишутся через запятую, русские слитно: 1,600 и 1600 это одно число.
|
|
44
|
-
const digits = (s) => (String(s).replace(/,(?=\d{3}\b)/g, '').match(/\d+/g) || []).join('|');
|
|
45
|
-
const lostNumbers = ru.filter((c, i) => {
|
|
46
|
-
const before = digits(all[i].value);
|
|
47
|
-
const after = digits(c.value);
|
|
48
|
-
return before && after && before.split('|').some((n) => !after.split('|').includes(n));
|
|
49
|
-
}).map((c) => `${c.id}: было ${digits(all[all.findIndex((x) => x.id === c.id)].value)}, стало ${digits(c.value)}`);
|
|
50
|
-
is('числа в тексте сохранились', lostNumbers, []);
|
|
51
|
-
|
|
52
|
-
is('подпись под оценкой переведена', localiseBasisNote('10 of 11 checks pass, 1 needs attention'), '10 из 11 проверок пройдено, 1 частично');
|
|
53
|
-
is('склонение на пяти', localiseBasisNote('3 of 5 checks pass, 2 need attention'), '3 из 5 проверок пройдено, 2 частично');
|
|
54
|
-
is('провал тоже склоняется', localiseBasisNote('1 of 3 checks pass, 1 needs attention, 1 fails'), '1 из 3 проверок пройдено, 1 частично, 1 провалена');
|
|
55
|
-
is('неизмеренное переводится', localiseBasisNote('not measured: no link data was collected'), 'не измерялось: no link data was collected');
|
|
56
|
-
|
|
57
|
-
is('английский язык ничего не меняет', localiseChecks(all, 'en'), all);
|
|
58
|
-
|
|
59
|
-
// Русские тексты не отправляют человека на английский сайт: подпись под баллом вела на
|
|
60
|
-
// oper-stack.com, и русский читатель попадал на страницу и цены не на своём языке.
|
|
61
|
-
{
|
|
62
|
-
const ru = readFileSync(new URL('./render.mjs', import.meta.url), 'utf8').split('\n')
|
|
63
|
-
.filter((l) => /[А-Яа-яЁё]/.test(l) && /oper-stack\.com/.test(l) && !l.trim().startsWith('*') && !l.trim().startsWith('//'));
|
|
64
|
-
is('в русских строках нет ссылок на английский сайт', ru, []);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
68
|
-
console.log(`\nперевод проверен на ${all.length} проверках, всё чисто`);
|