@operstack/audit 0.33.3 → 0.34.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/package.json +8 -3
- package/src/collect.mjs +222 -46
- package/src/consistency.mjs +90 -0
- package/src/etalon.mjs +37 -0
- package/src/i18n.mjs +26 -20
- package/src/index.mjs +6 -0
- package/src/narrative.mjs +57 -21
- package/src/niche.mjs +91 -0
- package/src/prompts.mjs +146 -5
- package/src/render.mjs +74 -16
- package/src/rendered.mjs +12 -2
- package/src/review.mjs +271 -0
- package/src/tasks-pdf.mjs +67 -0
- package/src/test-agent-surface.mjs +1 -1
- package/src/test-etalon.mjs +42 -0
- package/src/test-narrative.mjs +11 -2
- package/src/test-prompts.mjs +2 -2
- package/src/test-rendered.mjs +11 -2
- package/src/test-shopify-store.mjs +159 -0
- package/src/verify.mjs +347 -0
- package/src/visibility.mjs +40 -10
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Согласованность отчёта и файла задач перед выдачей (14.10.2026).
|
|
3
|
+
*
|
|
4
|
+
* Простым языком. Проверяющие на medeq-mc.ru раз за разом находили одно и то же семейство ошибок:
|
|
5
|
+
* в одном разделе «21 из 21», в другом выборка 20; скорость «до 0,8 с» в отчёте и «до 1,2 с» в
|
|
6
|
+
* задаче; «покупатели» у клиники; разделы 10 перед 09; оценка, которая не пересчитывается из
|
|
7
|
+
* перечисленных проверок. Всё это ловится без ИИ, сравнением текста с самим собой. Здесь каждое
|
|
8
|
+
* такое правило записано явно, и отчёт с хотя бы одним нарушением не печатается для клиента.
|
|
9
|
+
*/
|
|
10
|
+
import { verifyScores } from './collect.mjs';
|
|
11
|
+
import { vocabFor } from './niche.mjs';
|
|
12
|
+
|
|
13
|
+
const strip = (html) => String(html || '').replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/ | /g, ' ').replace(/&/g, '&').replace(/\s+/g, ' ');
|
|
14
|
+
|
|
15
|
+
/** Слова, которых не должно быть у ниши: советы чужой ниши в отчёте выглядят как копипаст. */
|
|
16
|
+
const FOREIGN = {
|
|
17
|
+
medical: [/покупател/i, /\bbuyers?\b/i, /корзин/i, /Получить расч[её]т/i, /Посмотреть цены/i, /RealEstateAgent/],
|
|
18
|
+
shop: [/RealEstateAgent/, /пациент/i, /\bpatients?\b/i],
|
|
19
|
+
realestate: [/пациент/i, /\bpatients?\b/i, /корзин/i],
|
|
20
|
+
b2b: [/пациент/i, /\bpatients?\b/i, /RealEstateAgent/],
|
|
21
|
+
services: [/пациент/i, /\bpatients?\b/i, /RealEstateAgent/],
|
|
22
|
+
other: [/RealEstateAgent/],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} audit
|
|
27
|
+
* @param {{ html?: string, tasks?: string }} parts отрисованный отчёт и текст файла задач
|
|
28
|
+
* @returns {{ ok: boolean, problems: string[] }}
|
|
29
|
+
*/
|
|
30
|
+
export function consistencyCheck(audit, { html = '', tasks = '' } = {}) {
|
|
31
|
+
const problems = [];
|
|
32
|
+
const ru = audit?.meta?.lang === 'ru';
|
|
33
|
+
const report = strip(html);
|
|
34
|
+
const both = `${report}\n${tasks}`;
|
|
35
|
+
const sample = (audit?.sample || []).filter((p) => p && p.title !== undefined).length;
|
|
36
|
+
|
|
37
|
+
// 1. Оценки пересчитываются из проверок.
|
|
38
|
+
try { for (const m of verifyScores(audit) || []) problems.push(`оценка: ${m}`); } catch { /* старый формат без проверки */ }
|
|
39
|
+
|
|
40
|
+
// 2. «X из N страниц выборки»: N везде равно размеру выборки.
|
|
41
|
+
const re = ru ? /(\d+) из (\d+) (?:страниц\S*|проверенн\S*) выборки/g : /(\d+) of (\d+) sampled page/g;
|
|
42
|
+
for (const m of both.matchAll(re)) {
|
|
43
|
+
// «из 12 страниц выборки, где есть цифры» считает от другого основания, это не выборка.
|
|
44
|
+
const after = both.slice(m.index + m[0].length, m.index + m[0].length + 40);
|
|
45
|
+
if (/^,? (?:где есть цифры|carrying figures|with figures)/.test(after)) continue;
|
|
46
|
+
const n = Number(m[2]);
|
|
47
|
+
if (sample && n !== sample) problems.push(`выборка названа как ${n}, а прочитано ${sample}: «${m[0]}»`);
|
|
48
|
+
if (Number(m[1]) > n) problems.push(`больше, чем всего: «${m[0]}»`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 3. Один порог на весь текст.
|
|
52
|
+
const ttfbThresholds = [...both.matchAll(ru ? /(?:до|не больше|меньше)\s+(\d+(?:,\d+)?|одной)\s+(?:секунд|с\b)/g : /(?:under|at most|or less|within)\s+(\d+(?:\.\d+)?)\s*(?:seconds?|s\b)/g)]
|
|
53
|
+
.map((m) => (m[1] === 'одной' ? '1' : m[1].replace(',', '.')));
|
|
54
|
+
const distinct = [...new Set(ttfbThresholds.filter((x) => Number(x) < 3))];
|
|
55
|
+
if (distinct.length > 1) problems.push(`разные пороги времени ответа: ${distinct.join(', ')} с`);
|
|
56
|
+
// Длина описания: только там, где рядом слово «описание» (у заголовка свой порог 30-60).
|
|
57
|
+
const descr = [...both.matchAll(/(?:описани|description)[^.]{0,80}?(?:от\s+)?(\d{2,3})\s*(?:-|до|to)\s*(\d{2,3})\s+(?:знак|char)/gi)].map((m) => `${m[1]}-${m[2]}`);
|
|
58
|
+
if (new Set(descr).size > 1) problems.push(`разные длины описания: ${[...new Set(descr)].join(', ')}`);
|
|
59
|
+
const cta = [...both.matchAll(ru ? /(?:первых|верхних)\s+(\d+|тридцати|пятнадцати)\s+процент/g : /(?:top|first)\s+(\d+|thirty|fifteen)\s+percent/g)].map((m) => m[1]);
|
|
60
|
+
if (new Set(cta).size > 1) problems.push(`разные доли первого экрана: ${[...new Set(cta)].join(', ')}`);
|
|
61
|
+
|
|
62
|
+
// 4. Слова чужой ниши.
|
|
63
|
+
const v = vocabFor(audit, ru ? 'ru' : 'en');
|
|
64
|
+
for (const bad of FOREIGN[v.niche] || []) {
|
|
65
|
+
const hit = both.match(bad);
|
|
66
|
+
if (hit) problems.push(`слово не из ниши «${v.niche}»: «${hit[0]}»`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 5. Разделы по порядку.
|
|
70
|
+
// Номера берутся только из надписей разделов (class="eyebrow"), а не из любого «35 · Слово» в тексте.
|
|
71
|
+
const nums = [...String(html).matchAll(/class="eyebrow">\s*(\d{2})\s?·/g)].map((m) => Number(m[1]));
|
|
72
|
+
for (let i = 1; i < nums.length; i++) if (nums[i] < nums[i - 1]) { problems.push(`раздел ${String(nums[i]).padStart(2, '0')} стоит после ${String(nums[i - 1]).padStart(2, '0')}`); break; }
|
|
73
|
+
|
|
74
|
+
// 6. Заготовки и служебный текст не уходят клиенту.
|
|
75
|
+
for (const bad of [/\{\{/, /на этой машине/i, /on this machine/i, /undefined/, /NaN/]) {
|
|
76
|
+
const hit = both.match(bad);
|
|
77
|
+
if (hit) problems.push(`служебный текст в отчёте: «${hit[0]}»`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 7. Тире, которых в текстах для клиента быть не должно (кроме цитат с сайта клиента в таблице страниц).
|
|
81
|
+
// Тире внутри цитат с сайта клиента («…», "…") это его текст, не наш.
|
|
82
|
+
const dashes = (tasks.replace(/«[^»]*»|"[^"]*"|"[^&]*"/g, '').match(/[—–]/g) || []).length;
|
|
83
|
+
if (dashes) problems.push(`длинное или среднее тире в файле задач: ${dashes}`);
|
|
84
|
+
|
|
85
|
+
// 8. Задачи, где «Как проверить» общее: это не проверка.
|
|
86
|
+
const generic = (tasks.match(ru ? /Прогони проверку сайта заново/g : /Run the site check again/g) || []).length;
|
|
87
|
+
if (generic) problems.push(`общая строка «Как проверить» вместо конкретной: ${generic}`);
|
|
88
|
+
|
|
89
|
+
return { ok: problems.length === 0, problems };
|
|
90
|
+
}
|
package/src/etalon.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Эталонный набор (14.10.2026): записанные ответы реальных сайтов и проверенные вручную ответы.
|
|
3
|
+
*
|
|
4
|
+
* Простым языком. На пяти сайтах (medeq-mc.ru, brainmd.com, neronov.pro, drivee.com и ещё одном)
|
|
5
|
+
* мы руками нашли ошибки движка. Здесь их ответы записаны в файл, а движок прогоняется по записи
|
|
6
|
+
* без сети: сбор, второй взгляд по сайту, черновик. Каждое правильное значение, сверенное вручную,
|
|
7
|
+
* проверяется. Новая версия движка не выкладывается, если хоть одно разошлось.
|
|
8
|
+
*/
|
|
9
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
10
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
|
|
12
|
+
const keyOf = (input, init = {}) => `${String(init.method || 'GET').toUpperCase()} ${typeof input === 'string' ? input : input.url}`;
|
|
13
|
+
|
|
14
|
+
/** Запись: подменяет fetch, складывает каждый ответ. */
|
|
15
|
+
export function recorder(realFetch = globalThis.fetch) {
|
|
16
|
+
const store = {};
|
|
17
|
+
const fetchRec = async (input, init = {}) => {
|
|
18
|
+
const r = await realFetch(input, init);
|
|
19
|
+
const body = await r.text();
|
|
20
|
+
const headers = {}; for (const h of ['content-type', 'location', 'link', 'x-robots-tag']) { const v = r.headers.get(h); if (v) headers[h] = v; }
|
|
21
|
+
store[keyOf(input, init)] = { status: r.status, url: r.url, headers, body };
|
|
22
|
+
return new Response([101, 204, 205, 304].includes(r.status) ? null : body, { status: r.status, headers });
|
|
23
|
+
};
|
|
24
|
+
return { fetch: fetchRec, save: (file) => writeFileSync(file, gzipSync(JSON.stringify(store))), store };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Воспроизведение: тот же fetch, только из файла. Чего нет в записи, отвечает 404. */
|
|
28
|
+
export function replayer(file) {
|
|
29
|
+
const store = JSON.parse(gunzipSync(readFileSync(file)).toString('utf8'));
|
|
30
|
+
return async (input, init = {}) => {
|
|
31
|
+
const hit = store[keyOf(input, init)] || store[`GET ${typeof input === 'string' ? input : input.url}`];
|
|
32
|
+
if (!hit) return new Response('', { status: 404 });
|
|
33
|
+
const res = new Response([101, 204, 205, 304].includes(hit.status) ? null : hit.body, { status: hit.status, headers: hit.headers });
|
|
34
|
+
Object.defineProperty(res, 'url', { value: hit.url || (typeof input === 'string' ? input : input.url) });
|
|
35
|
+
return res;
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/i18n.mjs
CHANGED
|
@@ -23,6 +23,8 @@ const plural = (n, one, few, many) => {
|
|
|
23
23
|
const pct = (n) => plural(Number(n), 'процент', 'процента', 'процентов');
|
|
24
24
|
const pages = (n) => plural(Number(n), 'страница', 'страницы', 'страниц');
|
|
25
25
|
const pagesOf = (a, b) => `${a} из ${pages(b)}`;
|
|
26
|
+
/** «из 21 страницы», «из 20 страниц»: после «из» родительный падеж. */
|
|
27
|
+
const pagesGen = (n) => (Number(n) % 10 === 1 && Number(n) % 100 !== 11 ? 'страницы' : 'страниц');
|
|
26
28
|
|
|
27
29
|
/** Перевод «сколько проверок пройдено» из подписи под оценкой. */
|
|
28
30
|
export function localiseBasisNote(note) {
|
|
@@ -80,27 +82,27 @@ const RU = {
|
|
|
80
82
|
},
|
|
81
83
|
},
|
|
82
84
|
'sitemap-foreign': { label: 'В карте сайта чужие хосты', value: (v) => `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')} на других хостах` },
|
|
83
|
-
'sitemap-health': { label: 'Адреса из карты сайта отвечают 200', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]}
|
|
85
|
+
'sitemap-health': { label: 'Адреса из карты сайта отвечают 200', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} адресов выборки из карты сайта отвечают 200${/redirect/.test(v) ? `, ${n[2]} перенаправляют` : ''}${/broken/.test(v) ? ', есть битые' : ''}`; } },
|
|
84
86
|
llms: {
|
|
85
87
|
label: 'llms.txt',
|
|
86
88
|
value: (v) => (/not present/.test(v) ? 'нет'
|
|
87
89
|
: /HTML page/.test(v) ? 'отдаёт HTML-страницу вместо текстового индекса'
|
|
88
90
|
: `${plural(Number(nums(v)[0]), 'ссылка', 'ссылки', 'ссылок')}, ${nums(v)[1]} на другие хосты`),
|
|
89
|
-
comment: (c) => (/nothing to read/.test(c) ? '
|
|
91
|
+
comment: (c) => (/no short site map for AI agents|nothing to read/.test(c) ? 'короткой карты сайта для ИИ-агентов нет, им приходится разбирать страницы самим; собрать её это работа на день' : c ? 'система ИИ может принять сайт за чужой' : ''),
|
|
90
92
|
},
|
|
91
93
|
// Где ноль означает отсутствие элемента, по-русски это тоже пишется словами, а не цифрой:
|
|
92
94
|
// «0 знаков» в задании для исполнителя читается как обрывок.
|
|
93
|
-
title: { label: 'Заголовок главной', value: (v) => { if (/^no title/i.test(v)) return 'заголовка у страницы нет'; const n = nums(v); return `${plural(Number(n[n.length - 1]), 'знак', 'знака', 'знаков')}${Number(n[n.length - 1]) > 60 ? ', в выдаче обрежется' : ', укладывается в выдачу'}`; }, comment: (c) => (/^short/.test(c) ? 'коротко: в заголовке нет слов, ради которых по нему кликают' : /cut in results/.test(c) ? 'в выдаче обрежется' : '') },
|
|
95
|
+
title: { label: 'Заголовок вкладки главной', value: (v) => { if (/^no title/i.test(v)) return 'заголовка у страницы нет'; const n = nums(v); return `${plural(Number(n[n.length - 1]), 'знак', 'знака', 'знаков')}${Number(n[n.length - 1]) > 60 ? ', в выдаче обрежется' : ', укладывается в выдачу'}`; }, comment: (c) => (/^short/.test(c) ? 'коротко: в заголовке нет слов, ради которых по нему кликают' : /cut in results/.test(c) ? 'в выдаче обрежется' : '') },
|
|
94
96
|
description: { label: 'Описание главной', value: (v) => (/^no meta description/i.test(v) ? 'описания у страницы нет' : /missing|none/i.test(v) ? 'нет' : /shortcode|garbage/i.test(v) ? 'содержит служебный код вместо текста' : `${plural(Number(nums(v)[0]), 'знак', 'знака', 'знаков')}`), comment: (c) => (/technical garbage/.test(c) ? 'в сниппете покажется технический мусор вместо текста' : /missing/.test(c) ? 'описания нет' : '') },
|
|
95
97
|
h1: { label: 'Заголовок H1 на главной', value: (v) => (/^no H1/i.test(v) ? 'заголовка H1 на странице нет' : `${plural(Number(nums(v)[0] || 0), 'заголовок', 'заголовка', 'заголовков')} H1`), comment: (c) => (/dilutes/.test(c) ? 'больше одного H1 размывает тему страницы' : /no H1/.test(c) ? 'H1 на странице нет' : '') },
|
|
96
98
|
canonical: { label: 'Канонический адрес', value: (v) => (/missing|none/i.test(v) ? 'не указан' : /of \d+ sampled/.test(v) ? `указан на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки` : 'указан'), comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
97
99
|
// Добавлено 17.09.2026 вместе с новыми проверками. Русский отчёт платный, английские строки в нём
|
|
98
100
|
// это такой же брак, как русский отчёт англоязычному покупателю.
|
|
99
|
-
'title-all': { label: 'Заголовок на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
101
|
+
'title-all': { label: 'Заголовок вкладки на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
100
102
|
'description-all': { label: 'Описание на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
101
103
|
'schema-all': { label: 'Разметка на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (/missing on/.test(c) ? `нет на: ${c.replace(/^missing on:\s*/, '')}` : '') },
|
|
102
104
|
'og-title': { label: 'og:title на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (c ? 'без него при пересылке ссылки видно голый адрес или чужой заголовок' : '') },
|
|
103
|
-
'og-image': { label: 'og:image на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (c ? '
|
|
105
|
+
'og-image': { label: 'og:image на страницах выборки', value: (v) => `есть на ${nums(v)[0]} из ${nums(v)[1]} страниц выборки`, comment: (c) => (c ? 'без og:image мессенджеры и соцсети показывают ссылку без картинки' : '') },
|
|
104
106
|
'twitter-card': { label: 'Карточка Twitter', value: (v) => (/no twitter/.test(v) ? 'не задана' : v.replace('twitter:card', 'тип')), comment: (c) => (c ? 'на неё опираются X и часть читалок, когда Open Graph неполный' : '') },
|
|
105
107
|
'breadcrumbs-schema': { label: 'Разметка хлебных крошек', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${pages(nums(v)[0])} выборки`), comment: (c) => (c ? 'крошки говорят системе, где страница стоит, и показываются в ответе рядом со ссылкой' : '') },
|
|
106
108
|
'howto-schema': { label: 'Разметка пошаговых инструкций', value: (v) => (/no step-by-step/.test(v) ? 'пошаговых страниц в выборке нет' : `есть на ${pages(nums(v)[0])} выборки`), comment: (c) => (c ? 'ставить только там, где страница правда ведёт по шагам: разметить ею что-то другое значит соврать системе' : '') },
|
|
@@ -120,13 +122,13 @@ const RU = {
|
|
|
120
122
|
'org-schema': { label: 'Разметка организации', value: (v) => (/not found/.test(v) ? 'не найдена: ответным системам не к чему привязать сайт' : 'есть') },
|
|
121
123
|
resources: { label: 'Вес главной страницы', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'файл скриптов', 'файла скриптов', 'файлов скриптов')}, ${plural(Number(n[1]), 'стиль', 'стиля', 'стилей')}, ${plural(Number(n[2]), 'изображение', 'изображения', 'изображений')}`; } },
|
|
122
124
|
hreflang: { label: 'hreflang', value: (v) => (/none/.test(v) ? 'нет, сайт одноязычный' : `${plural(Number(nums(v)[0]), 'альтернатива', 'альтернативы', 'альтернатив')}`) },
|
|
123
|
-
cms: { label: 'Движок сайта', value: (v) => v },
|
|
125
|
+
cms: { label: 'Движок сайта', value: (v) => v.replace(' (detected from the page code)', ' (определён по коду страницы)') },
|
|
124
126
|
xmlrpc: { label: 'xmlrpc.php', value: (v) => `отвечает ${nums(v)[0]}`, comment: () => 'открытая точка для атак, пользы для поиска никакой' },
|
|
125
|
-
alt: { label: 'Подписи к картинкам', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} картинок имеют подпись, это ${pct(n[2])}, на ${
|
|
126
|
-
'h1-sample': { label: 'Структура заголовков', value: (v) => (/one H1 per page/.test(v) ? 'по одному H1 на
|
|
127
|
+
alt: { label: 'Подписи к картинкам', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} картинок имеют подпись, это ${pct(n[2])}, на ${n[3]} ${Number(n[3]) % 10 === 1 && Number(n[3]) % 100 !== 11 ? 'странице' : 'страницах'} выборки`; } },
|
|
128
|
+
'h1-sample': { label: 'Структура заголовков', value: (v) => (/one H1 (?:per page|on every sampled page)/.test(v) ? 'по одному H1 на каждой странице выборки' : v.split('; ').map((part) => { const n = nums(part); return /no H1/.test(part) ? `H1 нет на ${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки` : `больше одного H1 на ${n[0]} из ${n[1]} ${pagesGen(n[1])} выборки`; }).join('; ')) },
|
|
127
129
|
'dup-titles': { label: 'Повторы заголовков', value: (v) => (/its own title/.test(v) ? 'у каждой проверенной страницы свой заголовок' : `${plural(Number(nums(v)[0]), 'заголовок повторяется', 'заголовка повторяются', 'заголовков повторяются')} на разных страницах`) },
|
|
128
|
-
thin: { label: 'Тонкие страницы', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${
|
|
129
|
-
utility: { label: 'Служебные страницы в карте сайта', value: (v) => `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')}, например ${v.split('such as ')[1] || ''}
|
|
130
|
+
thin: { label: 'Тонкие страницы', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${nums(v)[1]} ${pagesGen(nums(v)[1])} выборки короче 300 слов`) },
|
|
131
|
+
utility: { label: 'Служебные страницы в карте сайта', value: (v) => (/^1 URL: /.test(v) ? `1 адрес: ${v.slice(7)}` : `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')}, например ${v.split('such as ')[1] || ''}`), comment: (c) => { const map = { 'author pages': 'страницы авторов', 'tag pages': 'страницы тегов', 'cart and checkout pages': 'корзина и оформление заказа', 'wishlist pages': 'избранное', 'account pages': 'личный кабинет', feeds: 'ленты RSS', 'utility pages': 'служебные страницы' }; const k = String(c).replace(/ add noise to the index$/, '').split(', ').map((x) => map[x] || x); return `${k.join(', ')}: в индексе не нужны и засоряют его`; } },
|
|
130
132
|
'answer-first': {
|
|
131
133
|
label: 'Ответ в первом абзаце',
|
|
132
134
|
value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки начинаются с 20-90 слов, где есть цифра, сразу после H1`; },
|
|
@@ -134,25 +136,25 @@ const RU = {
|
|
|
134
136
|
},
|
|
135
137
|
sections: { label: 'Разбивка на разделы', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки имеют три и более подзаголовка H2`; }, comment: (c) => (c ? 'системы цитируют разделы, а не сплошной текст' : '') },
|
|
136
138
|
tables: { label: 'Таблицы в тексте', value: (v) => (/no table/.test(v) ? 'на проверенных страницах таблиц нет' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки используют таблицу`), comment: (c) => (c ? 'таблица это второй по частоте формат цитирования после первого абзаца' : '') },
|
|
137
|
-
sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])}
|
|
138
|
-
dates: { label: 'Открытые даты публикации', value: (v) => (/no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки показывают дату`) },
|
|
139
|
+
sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); if (n.length < 2) return /no sampled page carries a figure/.test(v) ? 'на страницах выборки нет цифр, которым нужен источник' : 'не измеряли'; return `${n[0]} из ${pages(n[1])} выборки, где есть цифры, называют, откуда взята цифра, словами или ссылкой рядом с ней`; }, comment: (c) => (c ? 'цифра без источника это первое, что система выбрасывает' : '') },
|
|
140
|
+
dates: { label: 'Открытые даты публикации', value: (v) => (/no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : /in the markup/.test(v) ? `${nums(v)[0]} из ${pages(nums(v)[1])} выборки указывают дату в разметке; та же дата видна на самой странице на ${nums(v)[2]}` : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки показывают дату`) },
|
|
139
141
|
'llms-health': { label: 'Страницы из llms.txt', value: (v) => (/^all /.test(v) ? `все ${nums(v)[0]} проверенных страниц отвечают и открыты для индексации` : v.replace(/(\d+) dead/, '$1 мёртвых').replace(/(\d+) redirecting/, '$1 с переадресацией').replace(/(\d+) closed to indexing/, '$1 закрытых от индексации').replace(/ of (\d+) checked: /, ' из $1 проверенных: ')), comment: (c) => (c ? 'ИИ читает этот файл вместо обхода сайта: каждая мёртвая или закрытая запись это страница, которую он процитирует неверно или не процитирует вовсе' : '') },
|
|
140
|
-
'agent-card': { label: 'Карточка агента (/.well-known/agent.json)', value: (v) => (/valid JSON/.test(v) ? 'есть, и это правильный JSON' : /not JSON/.test(v) ? 'есть, но это не JSON' : 'нет'), comment: (c) => (c ? 'ранний и необязательный стандарт:
|
|
142
|
+
'agent-card': { label: 'Карточка агента (/.well-known/agent.json)', value: (v) => (/valid JSON/.test(v) ? 'есть, и это правильный JSON' : /not JSON/.test(v) ? 'есть, но это не JSON' : 'нет'), comment: (c) => (c ? 'ранний и необязательный стандарт: файл описывает программного ИИ-агента, если он у компании есть; без своего агента он не нужен' : '') },
|
|
141
143
|
'api-catalog': { label: 'Каталог API (RFC 9727)', value: (v) => (/^present/.test(v) ? 'есть' : 'нет'), comment: (c) => (c ? 'необязательно, и нужно только если у бизнеса действительно есть API, который агент мог бы вызвать' : '') },
|
|
142
144
|
'agent-markdown': { label: 'Markdown-версия страниц', value: (v) => (/Link header/.test(v) ? 'предлагается через заголовок Link' : /next to the page/.test(v) ? 'лежит рядом со страницей' : 'не предлагается'), comment: (c) => (c ? 'агент, который просит text/markdown, получает HTML и вынужден его разбирать: отдать markdown дёшево и снимает догадки' : '') },
|
|
143
145
|
'links-profile': { label: 'Профиль внешних ссылок (необязательное дополнение)', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'ссылающийся домен', 'ссылающихся домена', 'ссылающихся доменов')}, ${plural(Number(n[1]), 'ссылка', 'ссылки', 'ссылок')}, ${pct(n[2])} передают вес, показатель авторитета ${n[3]} из 100`; }, comment: (c) => `${c.split('.')[0]}. Платный инструмент, для этого отчёта не обязателен и ни в одну оценку не входит.` },
|
|
144
|
-
'trust-entity': { label: 'Кто это, в машиночитаемом виде', value: (v) => (/no postal address/.test(v) ? 'почтового адреса в разметке нет' : 'почтовый адрес опубликован в разметке'), comment: (c) => (c ? 'рейтинговые системы и ответные движки по нему отличают одну компанию от другой' : '') },
|
|
146
|
+
'trust-entity': { label: 'Кто это, в машиночитаемом виде', value: (v) => (/no postal address/.test(v) ? 'почтового адреса в разметке нет' : /only the city and country/.test(v) ? 'в разметке только город и страна, без улицы' : 'почтовый адрес опубликован в разметке'), comment: (c) => (c ? 'рейтинговые системы и ответные движки по нему отличают одну компанию от другой' : '') },
|
|
145
147
|
'trust-contact': {
|
|
146
148
|
label: 'Прямые контакты',
|
|
147
149
|
value: (v) => (/no phone, email or messenger/.test(v) ? 'ни телефона, ни почты, ни мессенджера не опубликовано'
|
|
148
150
|
: v.replace(/\bphone\b/g, 'телефон').replace(/\bemail\b/g, 'почта').replace(/a messenger/g, 'мессенджер').replace(/ and /g, ' и ')),
|
|
149
151
|
},
|
|
150
|
-
'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' : `${nums(v)[0]}: ${v.split(': ').slice(1).join(': ')}`), comment: (c) => (c ? '
|
|
152
|
+
'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' : `${nums(v)[0]}: ${v.split(': ').slice(1).join(': ')}`), comment: (c) => (c ? 'бесплатный внешний сигнал: ссылки на свои профили в разметке' : '') },
|
|
151
153
|
'trust-about': { label: 'Страница о компании', value: (v) => (/no about/.test(v) ? 'страницы о компании нет или на неё не ведут ссылки' : 'на неё ведут ссылки с проверенных страниц') },
|
|
152
154
|
'trust-policies': { label: 'Политика и условия', value: (v) => (/neither/.test(v) ? 'ни политики конфиденциальности, ни условий на сайте нет' : `есть: ${v.replace(/privacy/g, 'политика').replace(/terms/g, 'условия').replace(/legal/g, 'правовая страница').replace(/cookie/g, 'куки').replace(/disclaimer/g, 'оговорка').replace(/consent/g, 'согласие на обработку данных')}`) },
|
|
153
155
|
'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки дают форму, контактную ссылку или страницу контактов`; } },
|
|
154
156
|
'conv-cta': { label: 'Призыв к действию в первом экране', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки ставят его в первых ${n[2]} процентах страницы`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
|
|
155
|
-
'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${pct(nums(v)[0])} вниз по странице, измерено на ${pages(nums(v)[1])}`), comment: (c) => (c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
|
|
157
|
+
'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/messenger links/.test(v) ? `формы нет, заявки идут через мессенджеры (на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки)` : /no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${pct(nums(v)[0])} вниз по странице, измерено на ${pages(nums(v)[1])}`), comment: (c) => (c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
|
|
156
158
|
'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`, comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
|
|
157
159
|
'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
|
|
158
160
|
};
|
|
@@ -172,18 +174,22 @@ export function localiseChecks(checks, lang = 'ru') {
|
|
|
172
174
|
const r = RU[c.id];
|
|
173
175
|
if (!r) return c;
|
|
174
176
|
const out = { ...c, label: r.label || c.label };
|
|
175
|
-
|
|
176
|
-
|
|
177
|
+
// Уже русский текст (его пишет второй взгляд по сайту) второй раз не переводим: перевод ждёт английский шаблон и испортил бы строку.
|
|
178
|
+
// Строки, которые уже написаны по-русски (второй взгляд по сайту ставит флаг localised), второй раз не переводим.
|
|
179
|
+
// Уже русская строка (без английских слов вне кавычек) второй раз не переводится: перевод ждёт английский шаблон.
|
|
180
|
+
const ruAlready = (x) => { const t = String(x || '').replace(/"[^"]*"|«[^»]*»/g, ''); return Boolean(c.localised) || (/[А-Яа-яЁё]/.test(t) && !/[A-Za-z]{3,}/.test(t)); };
|
|
181
|
+
try { if (r.value && !ruAlready(c.value)) out.value = r.value(c.value ?? ''); } catch { /* оставляем исходный текст */ }
|
|
182
|
+
try { if (r.comment && !ruAlready(c.comment)) out.comment = r.comment(c.comment ?? ''); } catch { /* оставляем исходный текст */ }
|
|
177
183
|
return out;
|
|
178
184
|
});
|
|
179
185
|
}
|
|
180
186
|
|
|
181
187
|
/** Русские названия шести областей. */
|
|
182
188
|
export const AREAS_RU = {
|
|
183
|
-
'SEO, technical': 'SEO,
|
|
189
|
+
'SEO, technical': 'SEO, доступ и адреса сайта',
|
|
184
190
|
'SEO, content and structure': 'SEO, контент и структура',
|
|
185
191
|
'AEO, answers and snippets': 'AEO, ответы и сниппеты',
|
|
186
|
-
'GEO, visibility in AI systems': 'GEO,
|
|
192
|
+
'GEO, visibility in AI systems': 'GEO, доступ и разметка для ИИ',
|
|
187
193
|
'Off-page and trust': 'Ссылки и доверие',
|
|
188
194
|
'Conversion and UX': 'Конверсия и удобство',
|
|
189
195
|
};
|
package/src/index.mjs
CHANGED
|
@@ -9,3 +9,9 @@ export { draftNarrative, stillEmpty } from './narrative.mjs';
|
|
|
9
9
|
export { agentPrompt, agentPrompts, renderAgentPrompts, firstFixParts, FIRST_FIX_IDS } from './prompts.mjs';
|
|
10
10
|
export { buildDemandMap, expandQueries, coverage, seedsFromPages, volumesViaTopvisor, topvisorClient, renderDemandHtml, renderDemandMarkdown, demandSourceRow, demandCsv, demandTotals, REGIONS as DEMAND_REGIONS, DEMAND_DEFAULTS } from './demand.mjs';
|
|
11
11
|
export { buildLlmsTxt, buildOrganisationSchema, fixTitlesAndDescriptions } from './handover.mjs';
|
|
12
|
+
export { secondLook, siteDigest, rescore, rebuildCritical, assertVerified, coldTtfb } from './verify.mjs';
|
|
13
|
+
export { reviewAndFix, reviewRound, confirmFlags, statementsOf, applyFix, toolFacts } from './review.mjs';
|
|
14
|
+
export { consistencyCheck } from './consistency.mjs';
|
|
15
|
+
export { detectNiche, vocabFor } from './niche.mjs';
|
|
16
|
+
export { renderTasksHtml, renderTasksPdf } from './tasks-pdf.mjs';
|
|
17
|
+
export { printFitted, pdfPageCount } from './render.mjs';
|
package/src/narrative.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
const T = {
|
|
21
21
|
ru: {
|
|
22
22
|
subjectFrom: (d) => `По описанию на главной: ${d}. Это то, что о вас прочитает и человек, и поисковик, и ИИ.`,
|
|
23
|
-
subjectNone: '
|
|
23
|
+
subjectNone: 'У главной страницы нет описания для поиска: сниппет в выдаче и в ответах ИИ поисковик подбирает сам из текста страницы.',
|
|
24
24
|
unknownLang: 'не указан в коде страниц',
|
|
25
25
|
/*
|
|
26
26
|
* Обращение к аналитику из клиентского текста убрано 17.09.2026. «Подтвердите» писалось для
|
|
@@ -29,16 +29,17 @@ const T = {
|
|
|
29
29
|
* само по себе находка: если из описания главной не понять, чем занимается компания, то это
|
|
30
30
|
* не понять ни человеку, ни поисковику, ни ИИ.
|
|
31
31
|
*/
|
|
32
|
-
sells: (d) => d ? `${d} (прочитано из описания главной страницы)` :
|
|
33
|
-
leadWhat: (h, n) =>
|
|
32
|
+
sells: (d, p) => d ? `${d} (прочитано из описания главной страницы)` : p ? `по тексту главной: «${p}»; описания для поиска у главной нет` : 'описания для поиска у главной нет, а из текста главной это не понять',
|
|
33
|
+
leadWhat: (h, n) => `Мы прочитали ${n} ${plural(n, 'страницу', 'страницы', 'страниц')} сайта ${h} так же, как их открывают поисковик и ИИ.`,
|
|
34
34
|
leadGood: (areas) => areas.length ? `Хорошо сделано вот что: ${listRu(areas)}.` : 'Ни одна из шести областей пока не выглядит сильной стороной.',
|
|
35
|
-
leadBad: (area, n) => n ?
|
|
36
|
-
verdict: (openN, mech) => openN
|
|
37
|
-
?
|
|
38
|
-
:
|
|
35
|
+
leadBad: (area, n) => n ? `Тянет сайт вниз другое: ${n} ${plural(n, 'проверка провалена', 'проверки провалены', 'проверок провалено')}, тяжелее всего дела в области «${area}».` : `Проваленных проверок нет, но до потолка не хватает мелочей, тяжелее всего в области «${area}».`,
|
|
36
|
+
verdict: (openN, mech, people = 'покупателей') => openN
|
|
37
|
+
? `${openN} ${plural(openN, 'проверка пройдена', 'проверки пройдены', 'проверок пройдены')} не полностью. Из них ${mech} ${plural(mech, 'правится', 'правятся', 'правятся')} технически, без переписывания текстов, то есть быстро, а ${openN - mech} ${plural(openN - mech, 'потребует', 'потребуют', 'потребуют')} ваших фактов: цифр, источников, ответов на вопросы ${people}.`
|
|
38
|
+
: `Главное: критичных поломок нет. Дальше сайт растёт не починкой, а текстами: ответами на вопросы ${people}, цифрами и источниками, по которым вас можно процитировать.`,
|
|
39
39
|
noteOffer: (cta, contact) => `Что видит человек, попавший на сайт: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
|
|
40
40
|
ctaYes: 'на первом экране есть понятный призыв к действию',
|
|
41
41
|
ctaNo: 'на первом экране нечего нажать, и это значит, что пришедший уходит',
|
|
42
|
+
ctaSome: 'призыв к действию на первом экране есть не на всех страницах',
|
|
42
43
|
contactYes: 'связаться можно почти с любой страницы',
|
|
43
44
|
contactNo: 'связаться с большинства страниц нельзя, человеку приходится искать',
|
|
44
45
|
strengthNone: 'Сильных сторон в текстах проверки не нашли.',
|
|
@@ -60,6 +61,7 @@ const T = {
|
|
|
60
61
|
convForm: (v) => v,
|
|
61
62
|
priceRow: 'Цена видна',
|
|
62
63
|
priceNo: 'на проверенных страницах цены нет',
|
|
64
|
+
priceYes: (n, total) => `да: цена есть на ${n} из ${total} проверенных страниц`,
|
|
63
65
|
closing: (host, bad) => bad
|
|
64
66
|
? `Мы посмотрели ${host} глазами машины и написали, что увидели, без общих слов. Сначала делается то, что чинится технически: это быстро и это видно в цифрах. Потом берутся тексты, и вот там понадобитесь вы, потому что цифры и факты про ваш бизнес знаете только вы.`
|
|
65
67
|
: `Мы посмотрели ${host} глазами машины. Технически сайт в порядке, чинить нечего, и это честный вывод, а не комплимент. Рост дальше только через тексты: ответы на вопросы покупателей с цифрами и источниками.`,
|
|
@@ -69,18 +71,19 @@ const T = {
|
|
|
69
71
|
},
|
|
70
72
|
en: {
|
|
71
73
|
subjectFrom: (d) => `From the homepage description: ${d}. This is what a person, a search engine and an AI all read about you.`,
|
|
72
|
-
subjectNone: 'The
|
|
74
|
+
subjectNone: 'The home page has no search description: search results and AI answers pick a snippet from the page text on their own.',
|
|
73
75
|
unknownLang: 'not declared in the page code',
|
|
74
|
-
sells: (d) => d ? `${d} (read from the homepage description)` :
|
|
75
|
-
leadWhat: (h, n) =>
|
|
76
|
+
sells: (d, p) => d ? `${d} (read from the homepage description)` : p ? `from the home page text: "${p}"; the home page has no search description` : 'the home page has no search description, and its text does not say it either',
|
|
77
|
+
leadWhat: (h, n) => `We read ${n} page${n === 1 ? '' : 's'} of ${h} the way machines read them: the way a search engine and an AI open them.`,
|
|
76
78
|
leadGood: (areas) => areas.length ? `What is done well: ${listEn(areas)}.` : 'None of the six areas reads as a strength yet.',
|
|
77
79
|
leadBad: (area, n) => n ? `What holds it back: ${n} check${n === 1 ? '' : 's'} failed, and the hardest area is "${area}".` : `No check is failing, but the last points are missing, mostly in "${area}".`,
|
|
78
|
-
verdict: (openN, mech) => openN
|
|
79
|
-
?
|
|
80
|
-
:
|
|
80
|
+
verdict: (openN, mech, people = 'buyers') => openN
|
|
81
|
+
? `${openN} check${openN === 1 ? ' is' : 's are'} not fully passed. ${mech} of them are mechanical, fixable without rewriting a word, which means quickly; the other ${openN - mech} need your facts: figures, sources, answers to what ${people} ask.`
|
|
82
|
+
: `The point: nothing is broken. From here the site grows through text, not repair: answers to what ${people} ask, figures, and the sources behind them.`,
|
|
81
83
|
noteOffer: (cta, contact) => `What a visitor meets: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
|
|
82
84
|
ctaYes: 'the first screen has a clear thing to do',
|
|
83
85
|
ctaNo: 'the first screen has nothing to click, which means arrivals leave',
|
|
86
|
+
ctaSome: 'only some pages give the first screen a clear thing to do',
|
|
84
87
|
contactYes: 'getting in touch is possible from almost any page',
|
|
85
88
|
contactNo: 'most pages offer no way to get in touch, so a visitor has to go looking',
|
|
86
89
|
strengthNone: 'The checks found no strength in the text.',
|
|
@@ -100,6 +103,7 @@ const T = {
|
|
|
100
103
|
offpageNote: 'We do not buy link data, so we print no number of links and no score built on one. What is checkable for free: whether the site says where else to find you, and whether that matches reality.',
|
|
101
104
|
priceRow: 'Price visible',
|
|
102
105
|
priceNo: 'no price on the pages we read',
|
|
106
|
+
priceYes: (n, total) => `yes: a price is shown on ${n} of ${total} pages we read`,
|
|
103
107
|
closing: (host, bad) => bad
|
|
104
108
|
? `We looked at ${host} the way a machine looks at it and wrote down what we saw, without generalities. What gets fixed first is what is mechanical: it is quick and it shows in the numbers. Then comes the text, and there we need you, because only you know the figures and facts of your business.`
|
|
105
109
|
: `We looked at ${host} the way a machine looks at it. Technically it is in order and there is nothing to repair, and that is a finding, not a compliment. Growth from here comes through text: answers to buyers' questions, with figures and sources.`,
|
|
@@ -114,12 +118,15 @@ function plural(n, one, few, many) {
|
|
|
114
118
|
return d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 10 || dd >= 20) ? few : many;
|
|
115
119
|
}
|
|
116
120
|
const sep = (a) => (a.some((x) => String(x).includes(',')) ? '; ' : ', ');
|
|
117
|
-
|
|
121
|
+
// Внутри фразы названия областей со строчной: «...; конверсия и удобство», но SEO, AEO и GEO как есть.
|
|
122
|
+
const lowerMid = (x) => (/^[А-ЯЁ][а-яё]/.test(x) ? x[0].toLowerCase() + x.slice(1) : x);
|
|
123
|
+
const listRu = (a0) => { const a = a0.map(lowerMid); return a.length < 2 ? a[0] || '' : sep(a) === '; ' ? a.join('; ') : `${a.slice(0, -1).join(', ')} и ${a[a.length - 1]}`; };
|
|
118
124
|
const listEn = (a) => (a.length < 2 ? a[0] || '' : sep(a) === '; ' ? a.join('; ') : `${a.slice(0, -1).join(', ')} and ${a[a.length - 1]}`);
|
|
119
125
|
|
|
120
126
|
/** Проверки, которые Fix закрывает технически: ими меряется «сколько чинится быстро». */
|
|
121
127
|
const MECHANICAL = new Set(['robots', 'robots-block', 'ai-search-access', 'sitemap', 'sitemap-foreign', 'sitemap-health', 'llms', 'title', 'description', 'h1', 'canonical', 'viewport', 'og', 'schema', 'faq-schema', 'org-schema', 'hreflang', 'xmlrpc', 'alt', 'h1-sample', 'dup-titles', 'utility', 'dates', 'trust-entity', 'trust-contact', 'trust-profiles', 'trust-about', 'trust-policies', 'conv-contact-path', 'conv-cta', 'conv-messenger', 'www', 'resources', 'conv-form-depth', 'conv-form-fields']);
|
|
122
128
|
|
|
129
|
+
import { vocabFor } from './niche.mjs';
|
|
123
130
|
import { localiseChecks, AREAS_RU } from './i18n.mjs';
|
|
124
131
|
import { FIX_ACTIONS } from './fix.mjs';
|
|
125
132
|
|
|
@@ -134,6 +141,7 @@ const open = (c) => c && (c.status === 'warn' || c.status === 'bad');
|
|
|
134
141
|
/** Проверки, которые закрывает только текст клиента: у них есть своя формулировка работы. */
|
|
135
142
|
const TEXT_WORK_IDS = new Set(['thin', 'answer-first', 'sources', 'sections', 'tables']);
|
|
136
143
|
|
|
144
|
+
export { MECHANICAL as MECHANICAL_IDS };
|
|
137
145
|
export function draftNarrative(audit, opts = {}) {
|
|
138
146
|
const lang = opts.lang || audit.meta?.lang || 'en';
|
|
139
147
|
const t = T[lang === 'ru' ? 'ru' : 'en'];
|
|
@@ -170,9 +178,10 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
170
178
|
|
|
171
179
|
// Резюме.
|
|
172
180
|
if (String(a.summary.lead).includes('{{')) {
|
|
181
|
+
// Число это выборка, а не размер сайта: «сайт из 19 страниц» про магазин на сотни товаров неправда (12.10.2026).
|
|
173
182
|
a.summary.lead = [t.leadWhat(host, sample.length), t.leadGood(strong), t.leadBad(weakest, bad.length)].join(' ');
|
|
174
183
|
}
|
|
175
|
-
if (String(a.summary.verdict).includes('{{')) a.summary.verdict = t.verdict(bad.length + warn.length, mechanical);
|
|
184
|
+
if (String(a.summary.verdict).includes('{{')) a.summary.verdict = t.verdict(bad.length + warn.length, mechanical, vocabFor(a, lang === 'ru' ? 'ru' : 'en').peopleGen);
|
|
176
185
|
// Приоритет это не название проверки, а действие: владелец должен прочитать и понять, что делать.
|
|
177
186
|
const actionText = (c) => {
|
|
178
187
|
const rule = FIX_ACTIONS[c.id];
|
|
@@ -188,7 +197,16 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
188
197
|
if (rule && rule.why) return L === 'ru' ? `Это работа с текстами: ${rule.why.ru}` : `This is text work: ${rule.why.en}`;
|
|
189
198
|
return c.label;
|
|
190
199
|
};
|
|
191
|
-
|
|
200
|
+
// Действие по замеру, а не по шаблону: «переписать описание, убрать служебный код» при пустом описании
|
|
201
|
+
// и «оставить ровно один H1» при нуле H1 вводили в заблуждение (14.10.2026).
|
|
202
|
+
const measuredAction = (c) => {
|
|
203
|
+
const ru = lang === 'ru'; const v = String(c.value || '');
|
|
204
|
+
if (c.id === 'description' && /нет|no meta description|missing|none/i.test(v)) return ru ? 'Написать описание главной в 120-160 знаков' : 'Write a home page description of 120 to 160 characters';
|
|
205
|
+
if (c.id === 'h1' && /нет|no H1/i.test(v)) return ru ? 'Добавить на главную один заголовок H1 о том, чем занимается компания' : 'Add one H1 to the home page saying what the business does';
|
|
206
|
+
if (c.id === 'alt') { const sm = (a.sample || []).filter((p) => p && p.title !== undefined); if (sm.length && sm.every((p) => p.imagesAltMissing === 0)) return ru ? 'Вписать подписи к фотографиям по их содержимому, значкам оформления оставить пустой alt' : 'Write alt text for the photos, leave decorative icons with an empty alt'; }
|
|
207
|
+
return null;
|
|
208
|
+
};
|
|
209
|
+
const action = (c) => `${measuredAction(c) || actionText(c)}. ${lang === 'ru' ? 'Сейчас' : 'Now'}: ${c.value}`;
|
|
192
210
|
// В приоритеты идут только те находки, для которых есть действие. У части проверок в FIX_ACTIONS
|
|
193
211
|
// вместо действия стоит довод против («это имеет смысл только тем, у кого правда есть API»), и
|
|
194
212
|
// такая строка первым пунктом плана читается как отговорка, а не как задача.
|
|
@@ -207,13 +225,13 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
207
225
|
a.overview.rows = (a.overview.rows || []).map(([k, v]) => {
|
|
208
226
|
if (!String(v).includes('{{')) return [k, v];
|
|
209
227
|
if (/language|язык/i.test(k)) return [k, a.meta?.language || t.unknownLang];
|
|
210
|
-
if (/sold
|
|
228
|
+
if (/sold|продаётся|продается|чем занимается/i.test(k)) return [k, t.sells(desc, String(home.offerSentence || home.firstPara || '').trim().slice(0, 220).replace(/[\s.,;:]+$/, ''))];
|
|
211
229
|
return [k, t.notMeasured];
|
|
212
230
|
});
|
|
213
231
|
if (String(a.overview.note).includes('{{')) {
|
|
214
232
|
const cta = byId(checks, 'conv-cta');
|
|
215
233
|
const path = byId(checks, 'conv-contact-path');
|
|
216
|
-
a.overview.note = t.noteOffer(open(cta) ? t.ctaNo : t.ctaYes, open(path) ? t.contactNo : t.contactYes);
|
|
234
|
+
a.overview.note = t.noteOffer(open(cta) ? (cta.status === 'bad' ? t.ctaNo : t.ctaSome) : t.ctaYes, open(path) ? t.contactNo : t.contactYes);
|
|
217
235
|
}
|
|
218
236
|
|
|
219
237
|
// Контент, AEO, GEO, ссылки, конверсия: строки собираются из своих же групп проверок.
|
|
@@ -225,7 +243,17 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
225
243
|
});
|
|
226
244
|
a.content.strengths = fillList(a.content.strengths, group('content').filter((c) => c.status === 'ok'), t.strengthNone);
|
|
227
245
|
a.content.weaknesses = fillList(a.content.weaknesses, group('content').filter(open), t.weakNone);
|
|
228
|
-
|
|
246
|
+
// «Структурные дыры» это страницы, которых нет, а не проваленные проверки текста (14.10.2026: туда попадали источники цифр).
|
|
247
|
+
{
|
|
248
|
+
const ru = lang === 'ru'; const miss = [];
|
|
249
|
+
const about = byId(checks, 'trust-about'); const pol = byId(checks, 'trust-policies');
|
|
250
|
+
if (about && about.status !== 'ok') miss.push(ru ? 'Нет страницы «О компании».' : 'There is no About page.');
|
|
251
|
+
if (pol && /политики конфиденциальности нет|neither a privacy policy|no privacy policy/i.test(String(pol.value))) miss.push(ru ? 'Нет страницы политики конфиденциальности.' : 'There is no privacy policy page.');
|
|
252
|
+
if (pol && /только в тексте страницы|only inside the text of/i.test(String(pol.value))) miss.push(ru ? 'Правила или условия услуг есть только внутри другой страницы, своей страницы у них нет.' : 'The terms sit inside another page and have no page of their own.');
|
|
253
|
+
const extraWeak = group('content').filter((c) => c.status === 'bad' && !group('content').filter(open).includes(c));
|
|
254
|
+
void extraWeak;
|
|
255
|
+
a.content.gaps = (a.content.gaps || []).some((x) => String(x).includes('{{')) ? (miss.length ? miss : [t.gapsNone]) : a.content.gaps;
|
|
256
|
+
}
|
|
229
257
|
a.aeo.works = fillList(a.aeo.works, group('aeo').filter((c) => c.status === 'ok'), t.aeoWorksNone);
|
|
230
258
|
a.aeo.blocks = fillList(a.aeo.blocks, group('aeo').filter(open), t.aeoBlocksNone);
|
|
231
259
|
if (String(a.aeo.recommendation).includes('{{')) {
|
|
@@ -248,14 +276,22 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
248
276
|
|
|
249
277
|
const profilesCheck = byId(checks, 'trust-profiles');
|
|
250
278
|
const profiles = profilesCheck && profilesCheck.status === 'ok' ? String(profilesCheck.value).split(/[,:]/).slice(1).map((s) => s.trim()).filter(Boolean) : [];
|
|
251
|
-
|
|
279
|
+
// Ссылки на профили на самих страницах (второй взгляд их видит) тоже профиль, просто не в разметке (14.10.2026).
|
|
280
|
+
const linkedOnly = profilesCheck && /ссылк\S* на сайте есть|ссылка на канал|pages link to/i.test(String(profilesCheck.value));
|
|
281
|
+
a.offpage.listed = (a.offpage.listed || []).map((x) => String(x).includes('{{') ? (linkedOnly ? (lang === 'ru' ? 'В разметке профили не заявлены, но на сайте есть ссылки на свои профили: их стоит вписать в sameAs.' : 'The markup declares no profiles, but the pages link to the company profiles: put them into sameAs.') : t.offpageListed(profiles)) : x);
|
|
252
282
|
if (String(a.offpage.note).includes('{{')) a.offpage.note = t.offpageNote;
|
|
253
283
|
|
|
254
284
|
a.conversion.rows = (a.conversion.rows || []).map(([label, status]) => {
|
|
255
285
|
if (!String(status).includes('{{')) return [label, status];
|
|
256
286
|
if (/form|форм/i.test(label)) { const c = byId(checks, 'conv-form-fields') || byId(checks, 'conv-form-depth'); return [label, c ? c.value : t.notMeasured]; }
|
|
257
287
|
if (/messenger|мессендж/i.test(label)) { const c = byId(checks, 'conv-messenger'); return [label, c ? c.value : t.notMeasured]; }
|
|
258
|
-
if (/price|цена/i.test(label))
|
|
288
|
+
if (/price|цена/i.test(label)) {
|
|
289
|
+
// Только по прочитанному: старая сборка без отметки цены остаётся «не измеряли», а не «цены нет».
|
|
290
|
+
const read = (a.sample || []).filter((p) => p && p.title !== undefined);
|
|
291
|
+
if (!read.length || read.every((p) => p.priceShown === undefined)) return [label, t.notMeasured];
|
|
292
|
+
const withPrice = read.filter((p) => p.priceShown).length;
|
|
293
|
+
return [label, withPrice ? t.priceYes(withPrice, read.length) : t.priceNo];
|
|
294
|
+
}
|
|
259
295
|
return [label, t.notMeasured];
|
|
260
296
|
});
|
|
261
297
|
|