@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.
- package/README.md +10 -0
- package/package.json +5 -3
- package/src/client-eye.mjs +339 -0
- package/src/collect.mjs +24 -10
- package/src/i18n.mjs +2 -1
- package/src/index.mjs +1 -0
- package/src/prompts.mjs +2 -2
- 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-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} проверках, всё чисто`);
|
package/src/test-narrative.mjs
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/** Черновик текста аудита: заполняет всё, ничего не выдумывает, читается человеком. */
|
|
3
|
-
import { draftNarrative, stillEmpty } from './narrative.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 audit = (over = {}) => ({
|
|
10
|
-
meta: { host: 'x.ru', site: 'https://x.ru', collectedAt: '2026-09-11T00:00:00.000Z', lang: 'en', language: '' },
|
|
11
|
-
client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: '2026-09-11', preparedBy: 'OperStack' },
|
|
12
|
-
scores: { 'SEO, technical': 9, 'SEO, content and structure': 4, 'AEO, answers and snippets': 6, 'GEO, visibility in AI systems': 8, 'Off-page and trust': null, 'Conversion and UX': 5 },
|
|
13
|
-
summary: { lead: '{{a}}', verdict: '{{b}}', priorities: ['{{1}}', '{{2}}', '{{3}}'] },
|
|
14
|
-
overview: { rows: [['CMS / stack', 'Astro'], ['Pages in sitemap', '40'], ['Language', '{{language}}'], ['What is sold', '{{products}}']], note: '{{note}}' },
|
|
15
|
-
content: { strengths: ['{{s}}'], weaknesses: ['{{w}}'], gaps: ['{{g}}'] },
|
|
16
|
-
aeo: { works: ['{{x}}'], blocks: ['{{y}}'], recommendation: '{{r}}' },
|
|
17
|
-
geo: { rows: [['Entity clarity', '{{s}}', '{{d}}'], ['Third-party mentions', '{{s}}', '{{d}}'], ['Reviews and PR', '{{s}}', '{{d}}']], callout: '{{c}}' },
|
|
18
|
-
offpage: { listed: ['{{l}}'], note: '{{n}}' },
|
|
19
|
-
conversion: { rows: [['Contact form', '{{s}}'], ['Price visible', '{{s}}'], ['Messengers', '{{s}}']] },
|
|
20
|
-
roadmap: [
|
|
21
|
-
{ badge: 'Week 1', title: 'Critical', items: ['Fix: Image alt text', '{{...}}'] },
|
|
22
|
-
{ badge: 'Weeks 2 to 4', title: 'Foundation', items: ['{{...}}'] },
|
|
23
|
-
{ badge: 'Months 2 to 3', title: 'Growth', items: ['{{...}}'] },
|
|
24
|
-
],
|
|
25
|
-
closing: '{{close}}',
|
|
26
|
-
sample: [{ url: 'https://x.ru/', title: 'Дом', description: 'Мы продаём станки', words: 900, answerFirst: false }],
|
|
27
|
-
checks: [
|
|
28
|
-
{ id: 'alt', group: 'onpage', label: 'Image alt text', status: 'bad', value: '3 of 10 images carry alt text (30%) across 5 sampled page(s)' },
|
|
29
|
-
{ id: 'thin', group: 'content', label: 'Thin pages in sample', status: 'warn', value: '4 of 20 pages under 300 words' },
|
|
30
|
-
{ id: 'answer-first', group: 'aeo', label: 'Answer-first opening paragraph', status: 'bad', value: '2 of 20 sampled page(s) open with a figure' },
|
|
31
|
-
{ id: 'sources', group: 'aeo', label: 'Sources named in the text', status: 'ok', value: '18 of 20 name a source' },
|
|
32
|
-
{ id: 'dates', group: 'content', label: 'Publication dates exposed', status: 'ok', value: '19 of 20 expose dates' },
|
|
33
|
-
{ id: 'conv-cta', group: 'conversion', label: 'A call to action in the first screen', status: 'bad', value: '1 of 20 put it in the first 30%' },
|
|
34
|
-
{ id: 'conv-messenger', group: 'conversion', label: 'Messenger link', status: 'ok', value: 'telegram found' },
|
|
35
|
-
{ id: 'trust-entity', group: 'trust', label: 'Who the business is', status: 'ok', value: 'city and country in the structured data' },
|
|
36
|
-
{ id: 'ai-search-access', group: 'geo', label: 'AI search fetchers allowed', status: 'ok', value: 'every AI search fetcher may read the site' },
|
|
37
|
-
],
|
|
38
|
-
...over,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const d = draftNarrative(audit(), { lang: 'ru' });
|
|
42
|
-
is('не осталось ни одной дыры', stillEmpty(d), []);
|
|
43
|
-
ok('имя клиента это адрес сайта, а не выдумка', d.client.name === 'x.ru');
|
|
44
|
-
ok('предмет взят с главной и помечен как прочитанный', d.client.subject.includes('Мы продаём станки') && /это то, что о вас прочитает/i.test(d.client.subject));
|
|
45
|
-
// Клиентский текст не обращается к аналитику: это товар, а не черновик на правку.
|
|
46
|
-
ok('в клиентском тексте нет обращений к аналитику', !/подтвердите|please confirm/i.test(JSON.stringify(d)));
|
|
47
|
-
|
|
48
|
-
// Русский аудит, русский отчёт: названия областей тоже русские. Раньше перевод срабатывал
|
|
49
|
-
// только для аудита, собранного по-английски, и в русский текст уезжали английские названия.
|
|
50
|
-
{
|
|
51
|
-
const ruAudit = audit();
|
|
52
|
-
ruAudit.meta.lang = 'ru';
|
|
53
|
-
const dru = draftNarrative(ruAudit, { lang: 'ru' });
|
|
54
|
-
ok('в русском отчёте по русскому аудиту нет английских названий областей',
|
|
55
|
-
!/SEO, technical|content and structure|answers and snippets|visibility in AI systems|Off-page and trust|Conversion and UX/.test(dru.summary.lead));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Ничего не выдумываем: там, где не мерили, так и написано
|
|
59
|
-
ok('упоминания на чужих сайтах помечены как неизмеренные', d.geo.rows[1][1] === 'не измеряли');
|
|
60
|
-
ok('и объяснено почему', /за деньги/.test(d.geo.rows[1][2]));
|
|
61
|
-
ok('в разделе ссылок сказано, что данные платные', /не покупаем данные/.test(d.offpage.note));
|
|
62
|
-
ok('язык не выдуман, если его нет в коде', /не указан/.test(d.overview.rows[2][1]));
|
|
63
|
-
// Число в начале резюме это выборка, а не размер сайта: «сайт из 19 страниц» о магазине на
|
|
64
|
-
// сотни товаров неправда (brainmd.com, 12.10.2026).
|
|
65
|
-
ok('резюме говорит, сколько прочитано, а не сколько страниц у сайта', /^Мы прочитали \d+ страниц/.test(d.summary.lead) && !/сайт из/.test(d.summary.lead));
|
|
66
|
-
{
|
|
67
|
-
const en = draftNarrative(audit(), { lang: 'en' });
|
|
68
|
-
ok('и по-английски тоже', /^We read \d+ pages? of /.test(en.summary.lead) && !/is a site of/.test(en.summary.lead));
|
|
69
|
-
const withLang = audit(); withLang.meta.language = 'en';
|
|
70
|
-
is('язык из кода страницы попадает в обзор словом', draftNarrative(withLang, { lang: 'en' }).overview.rows[2][1], 'English');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Числа сходятся между собой
|
|
74
|
-
const openN = 4; const mech = 2; // alt, conv-cta механические; thin и answer-first это тексты
|
|
75
|
-
ok('в выводе число открытых проверок верное', d.summary.verdict.startsWith(`${openN} `) && /не полностью/.test(d.summary.verdict));
|
|
76
|
-
ok('и механических не больше, чем всех', d.summary.verdict.includes(`${mech} правятся`) || d.summary.verdict.includes(`${mech} правится`));
|
|
77
|
-
|
|
78
|
-
// Приоритеты это действие, а не название проверки
|
|
79
|
-
ok('приоритет говорит, что делать', /Проставить подписи/.test(d.summary.priorities[0]));
|
|
80
|
-
ok('и показывает, как сейчас', /Сейчас:/.test(d.summary.priorities[0]));
|
|
81
|
-
ok('работа с текстами названа работой, а не отговоркой', d.summary.priorities.some((p) => /Наполнить короткие страницы|прямой ответ/.test(p)));
|
|
82
|
-
|
|
83
|
-
// Русский отчёт по английскому аудиту не должен содержать английских названий проверок
|
|
84
|
-
const ruText = JSON.stringify([d.summary, d.content, d.aeo, d.roadmap, d.closing]);
|
|
85
|
-
ok('в русском черновике нет английских названий проверок', !/Image alt text|Thin pages|call to action/.test(ruText));
|
|
86
|
-
ok('области названы по-русски', /доступ и адреса|техническая часть|конверсия/i.test(d.summary.lead));
|
|
87
|
-
|
|
88
|
-
// Дорожная карта: уже написанные строки переведены, пустые заполнены
|
|
89
|
-
ok('готовая строка карты переведена', d.roadmap[0].items[0].includes('подписи'));
|
|
90
|
-
ok('пустая строка карты заполнена делом', !d.roadmap[1].items[0].includes('{{'));
|
|
91
|
-
|
|
92
|
-
// Английский отчёт остаётся английским
|
|
93
|
-
const e = draftNarrative(audit(), { lang: 'en' });
|
|
94
|
-
is('по-английски тоже без дыр', stillEmpty(e), []);
|
|
95
|
-
ok('английский черновик без кириллицы', !/[А-Яа-яЁё]/.test(JSON.stringify([e.summary, e.aeo, e.closing])));
|
|
96
|
-
ok('и приоритет тоже действие', /Write alt text/.test(e.summary.priorities[0]));
|
|
97
|
-
|
|
98
|
-
// Здоровый сайт: вывод не пугает, а говорит правду
|
|
99
|
-
const clean = draftNarrative(audit({ checks: [
|
|
100
|
-
{ id: 'alt', group: 'onpage', label: 'Image alt text', status: 'ok', value: 'all images carry alt text' },
|
|
101
|
-
{ id: 'ai-search-access', group: 'geo', label: 'AI search fetchers allowed', status: 'ok', value: 'allowed' },
|
|
102
|
-
] }), { lang: 'ru' });
|
|
103
|
-
ok('на чистом сайте вывод говорит, что чинить нечего', /критичных поломок нет/.test(clean.summary.verdict));
|
|
104
|
-
ok('и заключение не выдумывает проблем', /нечего/.test(clean.closing));
|
|
105
|
-
|
|
106
|
-
// Ничего уже написанного человеком не затирается
|
|
107
|
-
const edited = audit();
|
|
108
|
-
edited.summary.lead = 'Текст, который написал аналитик.';
|
|
109
|
-
const keep = draftNarrative(edited, { lang: 'ru' });
|
|
110
|
-
is('правку аналитика черновик не трогает', keep.summary.lead, 'Текст, который написал аналитик.');
|
|
111
|
-
|
|
112
|
-
// Заблокированные фетчеры это прямой приговор, а не намёк
|
|
113
|
-
const blocked = draftNarrative(audit({ checks: [{ id: 'ai-search-access', group: 'geo', label: 'AI search fetchers allowed', status: 'bad', value: '3 blocked' }] }), { lang: 'ru' });
|
|
114
|
-
ok('если роботы ИИ закрыты, об этом сказано прямо', /невозможно физически/.test(blocked.geo.callout));
|
|
115
|
-
|
|
116
|
-
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
117
|
-
console.log('\nвсе тесты черновика прошли');
|