@operstack/audit 0.35.1 → 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 +94 -24
- package/src/etalon.mjs +1 -1
- package/src/fix.mjs +1 -1
- package/src/i18n.mjs +37 -23
- package/src/index.mjs +2 -0
- package/src/narrative.mjs +78 -31
- package/src/pagekind.mjs +6 -0
- package/src/presentation.mjs +95 -0
- package/src/prompts.mjs +58 -17
- package/src/render.mjs +41 -25
- package/src/rendered.mjs +8 -3
- package/src/tasks-pdf.mjs +1 -1
- package/src/verify.mjs +19 -12
- 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-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-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 -125
- 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/narrative.mjs
CHANGED
|
@@ -31,23 +31,23 @@ const T = {
|
|
|
31
31
|
*/
|
|
32
32
|
sells: (d, p) => d ? `${d} (прочитано из описания главной страницы)` : p ? `по тексту главной: «${p}»; описания для поиска у главной нет` : 'описания для поиска у главной нет, а из текста главной это не понять',
|
|
33
33
|
leadWhat: (h, n) => `Мы прочитали ${n} ${plural(n, 'страницу', 'страницы', 'страниц')} сайта ${h} так же, как их открывают поисковик и ИИ.`,
|
|
34
|
-
leadGood: (areas) => areas.length ? `Хорошо сделано вот что: ${listRu(areas)}.` : 'Ни одна из шести областей пока не выглядит сильной стороной.',
|
|
35
|
-
leadBad: (area, n) => n ? `Тянет сайт вниз другое: ${n} ${plural(n, 'проверка провалена', 'проверки провалены', 'проверок провалено')}
|
|
34
|
+
leadGood: (areas) => areas.length === 6 ? 'Все шесть областей проверки в хорошем состоянии.' : areas.length ? `Хорошо сделано вот что: ${listRu(areas)}.` : 'Ни одна из шести областей пока не выглядит сильной стороной.',
|
|
35
|
+
leadBad: (area, n, partial = 0) => n ? `Тянет сайт вниз другое: ${n} ${plural(n, 'проверка провалена', 'проверки провалены', 'проверок провалено')}${area ? `, тяжелее всего дела в области «${area}»` : ''}.` : area ? `Проваленных проверок нет, но до потолка не хватает мелочей, тяжелее всего в области «${area}».` : `Проваленных проверок нет${partial ? `, не полностью пройдены ${partial} ${plural(partial, 'проверка', 'проверки', 'проверок')}, они ниже` : ''}.`,
|
|
36
36
|
verdict: (openN, mech, people = 'покупателей') => openN
|
|
37
|
-
? `${openN} ${plural(openN, 'проверка пройдена', 'проверки пройдены', 'проверок пройдены')} не полностью.
|
|
37
|
+
? `${openN} ${plural(openN, 'проверка пройдена', 'проверки пройдены', 'проверок пройдены')} не полностью. ${mech === 0 ? `Все они требуют ваших фактов: цифр, источников, ответов на вопросы ${people}.` : mech === openN ? 'Все они правятся технически, без переписывания текстов, то есть быстро.' : `Из них ${mech} ${plural(mech, 'правится', 'правятся', 'правятся')} технически, без переписывания текстов, то есть быстро, а ${openN - mech} ${plural(openN - mech, 'потребует', 'потребуют', 'потребуют')} ваших фактов: цифр, источников, ответов на вопросы ${people}.`}`
|
|
38
38
|
: `Главное: критичных поломок нет. Дальше сайт растёт не починкой, а текстами: ответами на вопросы ${people}, цифрами и источниками, по которым вас можно процитировать.`,
|
|
39
39
|
noteOffer: (cta, contact) => `Что видит человек, попавший на сайт: ${cta}. ${contact[0].toUpperCase()}${contact.slice(1)}.`,
|
|
40
40
|
ctaYes: 'на первом экране есть понятный призыв к действию',
|
|
41
41
|
ctaNo: 'на первом экране нечего нажать, и это значит, что пришедший уходит',
|
|
42
42
|
ctaSome: 'призыв к действию на первом экране есть не на всех страницах',
|
|
43
|
-
contactYes: 'связаться можно
|
|
43
|
+
contactYes: 'связаться можно с любой проверенной страницы',
|
|
44
44
|
contactNo: 'связаться с большинства страниц нельзя, человеку приходится искать',
|
|
45
45
|
strengthNone: 'Сильных сторон в текстах проверки не нашли.',
|
|
46
|
-
weakNone: '
|
|
46
|
+
weakNone: 'В текстах существующих страниц слабых мест не нашли.',
|
|
47
47
|
gapsNone: 'Пробелов по страницам из выборки не видно.',
|
|
48
48
|
aeoWorksNone: 'Пока ничего из того, что помогает попасть в ответы, на сайте не работает.',
|
|
49
49
|
aeoBlocksNone: 'Прямых помех для попадания в ответы не нашли.',
|
|
50
|
-
aeoRec: (p) => p ? `Начните с одной страницы: ${p}. Поставьте в её начало прямой ответ с
|
|
50
|
+
aeoRec: (p) => p ? `Начните с одной страницы: ${p}. Поставьте в её начало прямой ответ с цифрой; если цифра не ваша цена или срок, назовите рядом её источник. Так страницу можно процитировать.` : 'Поставьте в начало каждой важной страницы прямой ответ с цифрой и назовите рядом источник. Цитируют то, что можно вырезать одним абзацем.',
|
|
51
51
|
geoEntity: ['Кто вы, понятно машине', (ok) => ok ? 'да' : 'нет'],
|
|
52
52
|
notMeasured: 'не измеряли',
|
|
53
53
|
geoMentions: 'Упоминания на чужих сайтах',
|
|
@@ -56,14 +56,14 @@ const T = {
|
|
|
56
56
|
geoCallout: (ok) => ok
|
|
57
57
|
? 'Роботы, которые достают страницу, чтобы процитировать её в ответе, на сайт пущены. Это половина дела: дальше вопрос в том, есть ли что цитировать.'
|
|
58
58
|
: 'Роботы, которые достают страницу для цитаты в ответе ИИ, на сайт не пущены. Пока это так, попасть в ответы невозможно физически, чего бы вы ни написали.',
|
|
59
|
-
offpageListed: (profiles) => profiles.length ?
|
|
59
|
+
offpageListed: (profiles) => profiles.length ? '' : 'Ни одного профиля компании на других площадках в коде сайта не заявлено.',
|
|
60
60
|
offpageNote: 'Мы не покупаем данные о ссылках, поэтому не пишем ни цифр про их количество, ни оценок за них. Что проверяемо бесплатно: сказал ли сайт сам, где ещё вас искать, и совпадает ли это с реальностью.',
|
|
61
61
|
convForm: (v) => v,
|
|
62
62
|
priceRow: 'Цена видна',
|
|
63
63
|
priceNo: 'на проверенных страницах цены нет',
|
|
64
64
|
priceYes: (n, total) => `да: цена есть на ${n} из ${total} проверенных страниц`,
|
|
65
65
|
closing: (host, bad) => bad
|
|
66
|
-
? `Мы посмотрели ${host} глазами машины и написали, что увидели, без общих слов. Сначала делается
|
|
66
|
+
? `Мы посмотрели ${host} глазами машины и написали, что увидели, без общих слов. Сначала делается первая неделя плана: три главных приоритета этого отчёта, потом остальные задачи по порядку. Для части задач нужны ваши факты: цифры, сроки, сведения о компании. Их знаете только вы, и исполнитель спросит их у вас.`
|
|
67
67
|
: `Мы посмотрели ${host} глазами машины. Технически сайт в порядке, чинить нечего, и это честный вывод, а не комплимент. Рост дальше только через тексты: ответы на вопросы покупателей с цифрами и источниками.`,
|
|
68
68
|
weekFill: 'Больше ничего срочного не нашли',
|
|
69
69
|
monthFill: 'Тексты по вашим фактам: ответы в начале страниц, источники к цифрам, недостающие страницы',
|
|
@@ -75,8 +75,8 @@ const T = {
|
|
|
75
75
|
unknownLang: 'not declared in the page code',
|
|
76
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
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.`,
|
|
78
|
-
leadGood: (areas) => areas.length ? `What is done well: ${listEn(areas)}.` : 'None of the six areas reads as a strength yet.',
|
|
79
|
-
leadBad: (area, n) => n ? `What holds it back: ${n} check${n === 1 ? '' : 's'} failed
|
|
78
|
+
leadGood: (areas) => areas.length === 6 ? 'All six areas of the check are in good shape.' : areas.length ? `What is done well: ${listEn(areas)}.` : 'None of the six areas reads as a strength yet.',
|
|
79
|
+
leadBad: (area, n, partial = 0) => n ? `What holds it back: ${n} check${n === 1 ? '' : 's'} failed${area ? `, and the hardest area is "${area}"` : ''}.` : area ? `No check is failing, but the last points are missing, mostly in "${area}".` : `No check is failing${partial ? `; ${partial} check${partial === 1 ? ' is' : 's are'} only partly passed, listed below` : ''}.`,
|
|
80
80
|
verdict: (openN, mech, people = 'buyers') => openN
|
|
81
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
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.`,
|
|
@@ -87,7 +87,7 @@ const T = {
|
|
|
87
87
|
contactYes: 'getting in touch is possible from almost any page',
|
|
88
88
|
contactNo: 'most pages offer no way to get in touch, so a visitor has to go looking',
|
|
89
89
|
strengthNone: 'The checks found no strength in the text.',
|
|
90
|
-
weakNone: '
|
|
90
|
+
weakNone: 'No weakness found in the text of the existing pages.',
|
|
91
91
|
gapsNone: 'No gaps are visible across the sampled pages.',
|
|
92
92
|
aeoWorksNone: 'Nothing that helps a page get quoted is working yet.',
|
|
93
93
|
aeoBlocksNone: 'Nothing directly blocks a page from being quoted.',
|
|
@@ -99,7 +99,7 @@ const T = {
|
|
|
99
99
|
geoCallout: (ok) => ok
|
|
100
100
|
? 'The fetchers that pull a page in order to quote it in an answer are allowed in. That is half the job: the rest is whether there is anything worth quoting.'
|
|
101
101
|
: 'The fetchers that pull a page to quote it in an AI answer are blocked. While that is true, being quoted is physically impossible, whatever you write.',
|
|
102
|
-
offpageListed: (profiles) => profiles.length ?
|
|
102
|
+
offpageListed: (profiles) => profiles.length ? '' : 'The site declares no company profile anywhere else.',
|
|
103
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.',
|
|
104
104
|
priceRow: 'Price visible',
|
|
105
105
|
priceNo: 'no price on the pages we read',
|
|
@@ -124,12 +124,13 @@ const listRu = (a0) => { const a = a0.map(lowerMid); return a.length < 2 ? a[0]
|
|
|
124
124
|
const listEn = (a) => (a.length < 2 ? a[0] || '' : sep(a) === '; ' ? a.join('; ') : `${a.slice(0, -1).join(', ')} and ${a[a.length - 1]}`);
|
|
125
125
|
|
|
126
126
|
/** Проверки, которые Fix закрывает технически: ими меряется «сколько чинится быстро». */
|
|
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-
|
|
127
|
+
const MECHANICAL = new Set(['ttfb', 'sections', '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-policies', 'conv-contact-path', 'conv-cta', 'conv-messenger', 'www', 'resources', 'conv-form-depth', 'conv-form-fields']);
|
|
128
128
|
|
|
129
129
|
import { vocabFor } from './niche.mjs';
|
|
130
130
|
import { localiseChecks, AREAS_RU } from './i18n.mjs';
|
|
131
131
|
import { FIX_ACTIONS } from './fix.mjs';
|
|
132
132
|
import { snapshotFacts } from './facts.mjs';
|
|
133
|
+
import { isListPage } from './pagekind.mjs';
|
|
133
134
|
|
|
134
135
|
const byId = (checks, id) => (checks || []).find((c) => c.id === id);
|
|
135
136
|
const open = (c) => c && (c.status === 'warn' || c.status === 'bad');
|
|
@@ -150,7 +151,8 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
150
151
|
// Отчёт по-русски по английскому аудиту: названия проверок и областей переводим здесь же,
|
|
151
152
|
// иначе владелец читает русский текст с английскими вставками и перестаёт нам верить.
|
|
152
153
|
const needsRu = lang === 'ru' && a.meta?.lang !== 'ru';
|
|
153
|
-
|
|
154
|
+
// Русский отчёт всегда читает переведённые строки: «Сейчас: 1: t.me» в приоритетах был сырым значением (cimes.ru, 14.10.2026).
|
|
155
|
+
const checks = lang === 'ru' ? localiseChecks(a.checks || [], 'ru') : (a.checks || []);
|
|
154
156
|
/*
|
|
155
157
|
* Названия областей переводим по языку ОТЧЁТА, а не по языку исходного аудита.
|
|
156
158
|
*
|
|
@@ -167,9 +169,15 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
167
169
|
|
|
168
170
|
const bad = checks.filter((c) => c.status === 'bad');
|
|
169
171
|
const warn = checks.filter((c) => c.status === 'warn');
|
|
170
|
-
|
|
172
|
+
// Карточка, адрес которой уже известен (listings), вписывается в разметку без фактов владельца: это техническая правка (cimes.ru, 15.10.2026).
|
|
173
|
+
const isMech = (c) => MECHANICAL.has(c.id) || (c.id === 'trust-profiles' && c.listings?.length > 0);
|
|
174
|
+
const mechanical = [...bad, ...warn].filter(isMech).length;
|
|
171
175
|
const scores = Object.entries(a.scores || {}).filter(([, v]) => typeof v === 'number');
|
|
172
|
-
|
|
176
|
+
// «Хорошо сделано» только для области без провалов и не больше чем с одной частичной проверкой: иначе её проблемы
|
|
177
|
+
// стоят в приоритетах, и абзац называет одну область и сильной, и главной бедой (cimes.ru, 14.10.2026).
|
|
178
|
+
const AREA_GROUPS = { 'SEO, technical': ['technical'], 'SEO, content and structure': ['onpage', 'content'], 'AEO, answers and snippets': ['aeo'], 'GEO, visibility in AI systems': ['geo'], 'Off-page and trust': ['offpage'], 'Conversion and UX': ['conversion'] };
|
|
179
|
+
const clean = (k) => { const g = AREA_GROUPS[k] || []; const own = checks.filter((c) => g.includes(c.group)); return !own.some((c) => c.status === 'bad') && own.filter((c) => c.status === 'warn').length <= 1; };
|
|
180
|
+
const strong = scores.filter(([k, v]) => v >= 8 && clean(k)).map(([k]) => areaName(k));
|
|
173
181
|
const weakest = areaName(scores.slice().sort((x, y) => x[1] - y[1])[0]?.[0] || '');
|
|
174
182
|
|
|
175
183
|
// Кто клиент. Имя это адрес сайта, предмет прочитан с главной и помечен как прочитанный.
|
|
@@ -180,7 +188,13 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
180
188
|
// Резюме.
|
|
181
189
|
if (String(a.summary.lead).includes('{{')) {
|
|
182
190
|
// Число это выборка, а не размер сайта: «сайт из 19 страниц» про магазин на сотни товаров неправда (12.10.2026).
|
|
183
|
-
|
|
191
|
+
// Слабейшая область, которая сама в «хорошо сделано» (оценка 8 и выше), слабой не называется: абзац противоречил себе (cimes.ru, 14.10.2026).
|
|
192
|
+
const weakestScore = scores.slice().sort((x, y) => x[1] - y[1])[0]?.[1];
|
|
193
|
+
// «Прочитали 20» и «ни на одной из 150 прочитанных» рядом без объяснения путали владельца (cimes.ru, 14.10.2026).
|
|
194
|
+
const readAll = Number(a.verification?.pagesRead) || 0;
|
|
195
|
+
const smTotal = Number((String(byId(checks, 'sitemap')?.value || '').match(/(\d+)/) || [])[1]) || 0;
|
|
196
|
+
const more = readAll > sample.length ? (lang === 'ru' ? `Кроме того, чтобы проверить каждое «нет на сайте», мы открыли ${readAll}${smTotal > readAll ? ` из ${smTotal}` : ''} ${plural(readAll, 'адрес', 'адреса', 'адресов').replace(/^\d+ /, '')} карты сайта (${sample.length} страниц выборки входят в это число).` : `We also read ${readAll} pages from the sitemap to check every "not on the site".`) : '';
|
|
197
|
+
a.summary.lead = [t.leadWhat(host, sample.length), more, t.leadGood(strong), t.leadBad(weakestScore >= 8 ? '' : weakest, bad.length, warn.length)].join(' ');
|
|
184
198
|
}
|
|
185
199
|
if (String(a.summary.verdict).includes('{{')) a.summary.verdict = t.verdict(bad.length + warn.length, mechanical, vocabFor(a, lang === 'ru' ? 'ru' : 'en').peopleGen);
|
|
186
200
|
// Приоритет это не название проверки, а действие: владелец должен прочитать и понять, что делать.
|
|
@@ -204,10 +218,17 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
204
218
|
const ru = lang === 'ru'; const v = String(c.value || '');
|
|
205
219
|
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';
|
|
206
220
|
if (c.id === 'h1' && /нет|no H1/i.test(v)) return ru ? 'Добавить на главную один заголовок H1 о том, чем занимается компания' : 'Add one H1 to the home page saying what the business does';
|
|
221
|
+
// Какие страницы разбивать, называем: «разбить страницу» без адреса владельцу не говорит, о какой речь (cimes.ru, 14.10.2026).
|
|
222
|
+
if (c.id === 'answer-first') { const miss = (a.sample || []).filter((p) => p && p.title !== undefined && !p.answerFirst && !isListPage(p)).map((p) => { try { return new URL(p.url).pathname; } catch { return p.url; } }); if (miss.length) return ru ? `Поставить в начало страниц прямой ответ с цифрой, начиная с ${miss.slice(0, 3).join(', ')} (всего таких страниц с текстом в выборке ${miss.length}, список в файле задач; страницы-списки переписывать не нужно)` : `Put a direct answer with a figure at the top of the pages, starting with ${miss.slice(0, 3).join(', ')} (${miss.length} such sampled pages, listed in the tasks file)`; }
|
|
223
|
+
if (c.id === 'trust-profiles' && c.listings && c.listings.length) return ru ? `Указать в разметке карточку компании, которая уже показана на сайте: ${c.listings[0]}` : `Declare in the markup the company listing the site already shows: ${c.listings[0]}`;
|
|
224
|
+
if (c.id === 'trust-profiles' && c.messengersOnly) return ru ? 'Найти и указать в разметке настоящие профили компании на площадках' : 'Find and declare the company\'s real platform profiles in the markup';
|
|
225
|
+
if (c.id === 'trust-about' && c.status === 'bad') return ru ? 'Сделать страницу «О компании» и поставить ссылку на неё в меню или подвал' : 'Make an About page and link it from the menu or footer';
|
|
226
|
+
if (c.id === 'sections') { const long = (a.sample || []).filter((p) => p && p.title !== undefined && (p.words || 0) >= 600 && (p.h2Count || 0) < 3 && !isListPage(p)).slice(0, 3).map((p) => { try { return new URL(p.url).pathname; } catch { return p.url; } }); const few = Number((String(c.value).match(/(\d+) из|(\d+) of/) || [])[1] || 0); const rest = (a.sample || []).filter((p) => p && p.title !== undefined && (p.h2Count || 0) < 3 && !long.some((x) => p.url.endsWith(x))); const isList = isListPage; const nList = rest.filter(isList).length; const nShort = rest.length - nList; const why = few > long.length ? (ru ? ` (остальные ${nShort && nList ? 'короткие или это страницы-списки' : nList ? 'это страницы-списки' : 'короткие'}, их разбивать не нужно)` : ` (the others are ${nShort && nList ? 'short or list pages' : nList ? 'list pages' : 'short'})`) : ''; if (long.length) return ru ? `Разбить на разделы длинные страницы: ${long.join(', ')}${why}` : `Split the long pages into sections: ${long.join(', ')}${why}`; }
|
|
207
227
|
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'; }
|
|
208
228
|
return null;
|
|
209
229
|
};
|
|
210
|
-
|
|
230
|
+
// Пункт приоритета это законченное предложение: с точкой в конце (cimes.ru, 15.10.2026).
|
|
231
|
+
const action = (c) => `${measuredAction(c) || actionText(c)}. ${lang === 'ru' ? 'Сейчас' : 'Now'}: ${String(c.value).replace(/[.\s]+$/, '')}.`;
|
|
211
232
|
// В приоритеты идут только те находки, для которых есть действие. У части проверок в FIX_ACTIONS
|
|
212
233
|
// вместо действия стоит довод против («это имеет смысл только тем, у кого правда есть API»), и
|
|
213
234
|
// такая строка первым пунктом плана читается как отговорка, а не как задача.
|
|
@@ -221,11 +242,25 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
221
242
|
const c = top[i];
|
|
222
243
|
return c ? action(c) : (lang === 'ru' ? 'Ничего срочного больше не нашли' : 'Nothing else is urgent');
|
|
223
244
|
});
|
|
245
|
+
// Один список «что первым»: три приоритета, раздел «Что чинить первым» и первая неделя плана называют одно и то же.
|
|
246
|
+
// Раньше это были три разных списка, и владелец не понимал, с чего начинать (cimes.ru, 14.10.2026).
|
|
247
|
+
if (top.length) {
|
|
248
|
+
a.priorityIds = top.map((c) => c.id);
|
|
249
|
+
const fixWord = lang === 'ru' ? 'Исправить' : 'Fix';
|
|
250
|
+
a.critical = top.map((c) => ({ title: c.label, text: [c.value, c.comment ? `${String(c.comment)[0].toUpperCase()}${String(c.comment).slice(1)}` : ''].filter(Boolean).join('. '), level: c.status === 'bad' ? 'bad' : 'warn' }));
|
|
251
|
+
const w1 = (a.roadmap || [])[0];
|
|
252
|
+
if (w1 && /Неделя 1|Week 1/.test(String(w1.badge))) {
|
|
253
|
+
w1.items = top.map((c) => `${fixWord}: ${c.label}`);
|
|
254
|
+
const w2 = a.roadmap[1];
|
|
255
|
+
if (w2 && Array.isArray(w2.items)) w2.items = w2.items.filter((i) => !top.some((c) => String(i).includes(c.label)));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
224
258
|
|
|
225
259
|
// Обзор.
|
|
226
260
|
a.overview.rows = (a.overview.rows || []).map(([k, v]) => {
|
|
227
261
|
if (!String(v).includes('{{')) return [k, v];
|
|
228
|
-
|
|
262
|
+
// «ru» владелец не читает: название языка словом (cimes.ru, 14.10.2026).
|
|
263
|
+
if (/language|язык/i.test(k)) { const code = String(a.meta?.language || '').toLowerCase().slice(0, 2); const RU_NAMES = { ru: 'русский', en: 'английский', uk: 'украинский', kk: 'казахский', de: 'немецкий', fr: 'французский', es: 'испанский', it: 'итальянский', th: 'тайский', tr: 'турецкий', ar: 'арабский', zh: 'китайский' }; const EN_NAMES = { ru: 'Russian', en: 'English', uk: 'Ukrainian', de: 'German', fr: 'French', es: 'Spanish', it: 'Italian', th: 'Thai', tr: 'Turkish', ar: 'Arabic', zh: 'Chinese' }; return [k, code ? ((lang === 'ru' ? RU_NAMES : EN_NAMES)[code] || a.meta.language) : t.unknownLang]; }
|
|
229
264
|
if (/sold|продаётся|продается|чем занимается/i.test(k)) return [k, t.sells(desc, String(home.offerSentence || home.firstPara || '').trim().slice(0, 220).replace(/[\s.,;:]+$/, ''))];
|
|
230
265
|
return [k, t.notMeasured];
|
|
231
266
|
});
|
|
@@ -237,13 +272,21 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
237
272
|
|
|
238
273
|
// Контент, AEO, GEO, ссылки, конверсия: строки собираются из своих же групп проверок.
|
|
239
274
|
const group = (g) => checks.filter((c) => c.group === g);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
275
|
+
// Строк в шаблоне меньше, чем найденного: остальное дописываем (до четырёх), иначе «Что мешает» молчит о второй проблеме (cimes.ru, 14.10.2026).
|
|
276
|
+
const fillList = (arr, source, empty) => {
|
|
277
|
+
const slots = (arr || []).filter((x) => String(x).includes('{{')).length;
|
|
278
|
+
const out = (arr || []).map((x, i) => {
|
|
279
|
+
if (!String(x).includes('{{')) return x;
|
|
280
|
+
const c = source[i];
|
|
281
|
+
return c ? `${c.label}: ${c.value}` : empty;
|
|
282
|
+
});
|
|
283
|
+
if (slots && source.length > slots) out.push(...source.slice(slots, 4).map((c) => `${c.label}: ${c.value}`));
|
|
284
|
+
return out;
|
|
285
|
+
};
|
|
245
286
|
a.content.strengths = fillList(a.content.strengths, group('content').filter((c) => c.status === 'ok'), t.strengthNone);
|
|
246
|
-
|
|
287
|
+
// Слабые места текстов могут быть в разделе ответов ИИ (первый абзац, разделы): тогда «не нашли» здесь противоречит ему (cimes.ru, 14.10.2026).
|
|
288
|
+
const aeoOpen = group('aeo').filter(open);
|
|
289
|
+
a.content.weaknesses = fillList(a.content.weaknesses, group('content').filter(open), aeoOpen.length ? (lang === 'ru' ? `Слабые места текстов относятся к ответам ИИ и описаны ниже, в разделе «Ответные системы: AEO»: ${aeoOpen.slice(0, 3).map((c) => c.label.toLowerCase()).join(', ')}.` : `The text weaknesses concern AI answers and are covered in that section below: ${aeoOpen.slice(0, 3).map((c) => c.label.toLowerCase()).join(', ')}.`) : t.weakNone);
|
|
247
290
|
// «Структурные дыры» это страницы, которых нет, а не проваленные проверки текста (14.10.2026: туда попадали источники цифр).
|
|
248
291
|
{
|
|
249
292
|
const ru = lang === 'ru'; const miss = [];
|
|
@@ -258,7 +301,8 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
258
301
|
a.aeo.works = fillList(a.aeo.works, group('aeo').filter((c) => c.status === 'ok'), t.aeoWorksNone);
|
|
259
302
|
a.aeo.blocks = fillList(a.aeo.blocks, group('aeo').filter(open), t.aeoBlocksNone);
|
|
260
303
|
if (String(a.aeo.recommendation).includes('{{')) {
|
|
261
|
-
|
|
304
|
+
// Та же первая страница, что в приоритете и в задаче: три места советовали начать с разных (cimes.ru, 15.10.2026).
|
|
305
|
+
const thinnest = sample.filter((p) => p.answerFirst === false && !isListPage(p))[0] || sample.filter((p) => p.answerFirst === false)[0];
|
|
262
306
|
a.aeo.recommendation = t.aeoRec(thinnest ? thinnest.url : '');
|
|
263
307
|
}
|
|
264
308
|
|
|
@@ -276,10 +320,13 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
276
320
|
}
|
|
277
321
|
|
|
278
322
|
const profilesCheck = byId(checks, 'trust-profiles');
|
|
279
|
-
|
|
323
|
+
// Профиль в разметке есть и при статусе «частично» (один профиль): «ни одного» рядом с «1: t.me» было противоречием (cimes.ru, 14.10.2026).
|
|
324
|
+
const profiles = profilesCheck && /^(\d+:|указан|\d+ profiles?:)/.test(String(profilesCheck.value)) ? String(profilesCheck.value).split(/[,:]/).slice(1).map((s) => s.trim()).filter(Boolean) : [];
|
|
280
325
|
// Ссылки на профили на самих страницах (второй взгляд их видит) тоже профиль, просто не в разметке (14.10.2026).
|
|
281
326
|
const linkedOnly = profilesCheck && /ссылк\S* на сайте есть|ссылка на канал|pages link to/i.test(String(profilesCheck.value));
|
|
282
|
-
|
|
327
|
+
// Карточка показана на сайте, но не в разметке, или в разметке одни мессенджеры: «ни одного профиля» было бы неправдой (cimes.ru, 15.10.2026).
|
|
328
|
+
const listedNote = profilesCheck?.listings?.length ? (lang === 'ru' ? `Карточка компании ${profilesCheck.listings[0]} показана на сайте, но в разметке не указана.` : `The company listing ${profilesCheck.listings[0]} is shown on the site but not declared in the markup.`) : profilesCheck?.messengersOnly?.length ? (lang === 'ru' ? 'В разметке указаны только мессенджеры, профилей на площадках нет.' : 'The markup lists only messengers, no platform profiles.') : '';
|
|
329
|
+
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.') : (listedNote || t.offpageListed(profiles))) : x);
|
|
283
330
|
if (String(a.offpage.note).includes('{{')) a.offpage.note = t.offpageNote;
|
|
284
331
|
|
|
285
332
|
a.conversion.rows = (a.conversion.rows || []).map(([label, status]) => {
|
|
@@ -303,15 +350,15 @@ export function draftNarrative(audit, opts = {}) {
|
|
|
303
350
|
const labelToCheck = new Map(checks.map((c) => [c.label, c]));
|
|
304
351
|
const englishLabel = new Map((a.checks || []).map((c, i) => [c.label, checks[i]]));
|
|
305
352
|
const retitle = (item) => {
|
|
306
|
-
const m = /^Fix:\s*(.+)$/.exec(String(item));
|
|
353
|
+
const m = /^(?:Fix|Исправить):\s*(.+)$/.exec(String(item));
|
|
307
354
|
if (!m) return item;
|
|
308
355
|
const c = labelToCheck.get(m[1]) || englishLabel.get(m[1]);
|
|
309
|
-
return c ? actionText(c) : item;
|
|
356
|
+
return c ? (measuredAction(c) || actionText(c)) : item;
|
|
310
357
|
};
|
|
311
358
|
a.roadmap = (a.roadmap || []).map((phase, pi) => ({
|
|
312
359
|
...phase,
|
|
313
360
|
items: (phase.items || []).map((item, ii) => {
|
|
314
|
-
if (!String(item).includes('{{')) return
|
|
361
|
+
if (!String(item).includes('{{')) return retitle(item);
|
|
315
362
|
const pool = pi === 0 ? mech : pi === 1 ? textWork : [];
|
|
316
363
|
const c = pool[ii];
|
|
317
364
|
if (c) return actionText(c);
|
package/src/pagekind.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Вид страницы, одно правило на весь движок (15.10.2026, cimes.ru). Страница-список (CollectionPage, ItemList)
|
|
3
|
+
* разводит по подразделам: переписывать её начало и разбивать на разделы не нужно. Раньше задача про разделы
|
|
4
|
+
* признавала /services/integration/ списком, а задача про первый абзац тут же требовала его переписать.
|
|
5
|
+
*/
|
|
6
|
+
export const isListPage = (p) => (p?.schemaTypes || []).some((t) => /^(CollectionPage|ItemList)$/.test(t));
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Проверка оформления отчёта кодом перед сборкой PDF (14.10.2026, задание «проверка глазами»).
|
|
3
|
+
*
|
|
4
|
+
* Простым языком. Проверка фактов ловит неправду, но не ловит сырой вид: пустой раздел плана,
|
|
5
|
+
* «Сейчас: 1: t.me», фразу, оборванную на предлоге, «Норма» при 1 из 20, «Тонкие страницы» в сильных
|
|
6
|
+
* сторонах. Отчёт cimes.ru ушёл бы клиенту таким. Здесь восемь правил, каждое смотрит готовый текст
|
|
7
|
+
* отчёта и задач. Хоть одна находка: отчёт не собирается и не уходит, причина пишется словами.
|
|
8
|
+
* Модель не нужна, денег это не стоит.
|
|
9
|
+
*/
|
|
10
|
+
import { statementsOf } from './review.mjs';
|
|
11
|
+
import { agentPrompts } from './prompts.mjs';
|
|
12
|
+
import { localiseChecks } from './i18n.mjs';
|
|
13
|
+
|
|
14
|
+
/** Пороги в одном месте: «Норма» у строки «X из N» невозможна, если X меньше половины N и меньше трёх. */
|
|
15
|
+
export const STATUS_OK_SHARE = 0.5;
|
|
16
|
+
export const STATUS_OK_MIN = 3;
|
|
17
|
+
|
|
18
|
+
/** Строки, где «X из N» считает страницы, на которых проблема: X это сколько страниц должна назвать задача. */
|
|
19
|
+
export const PROBLEM_SHARE = new Set(['sections', 'answer-first']);
|
|
20
|
+
/** Строки, где «X из N» считает страницы, на которых всё хорошо: чем больше X, тем лучше. */
|
|
21
|
+
export const GOOD_SHARE = new Set(['dates', 'conv-cta', 'schema-all', 'sources', 'sitemap-health', 'title-all', 'description-all', 'canonical-all', 'og-all', 'faq-visible']);
|
|
22
|
+
|
|
23
|
+
const SERVICE = [
|
|
24
|
+
[/(^|[\s:(])\d+: [a-z0-9-]+\.[a-z]{2,}(?=[\s,.;)]|$)/i, 'служебное значение вида «1: t.me»'],
|
|
25
|
+
[/\bundefined\b/, '«undefined»'],
|
|
26
|
+
[/(?<!\/dev\/)\bnull\b/, '«null»'],
|
|
27
|
+
[/\bNaN\b/, '«NaN»'],
|
|
28
|
+
[/\[object /, '«[object …]»'],
|
|
29
|
+
[/\{\{|\}\}|(^|\s)[{}](\s|$)/, 'фигурная скобка шаблона'],
|
|
30
|
+
[/\b(chk|task|sum|sub|ov|road|cont|aeo|geo|conv|off):[a-z][\w-]*/, 'голый ключ вида chk:…'],
|
|
31
|
+
];
|
|
32
|
+
// Конец на предлоге или союзе, либо «на 1» без существительного («та же дата видна на самой странице на 1», cimes.ru).
|
|
33
|
+
const DANGLING = /(?:^|\s)(на|в|во|из|с|со|к|ко|по|о|об|от|до|для|при|про|у|и|или|а|но|что|of|on|in|at|to|for|and|or|the|with|from|by)$|(?:^|\s)(?:на|у|по|on) \d+\.?$/i;
|
|
34
|
+
const NEGATIVE_LABEL = /тонк|короткие|ошибк|битые|битых|пуст|дубл|нет |не найден|thin|broken|missing|duplicate|empty|error/i;
|
|
35
|
+
const QUOTED = /«[^»]*»|"[^"]*"|“[^”]*”/g;
|
|
36
|
+
/** Поля, где стоит текст с сайта клиента: тире там его, не наше. */
|
|
37
|
+
const SITE_TEXT = (id) => id === 'sub:subject' || /^ov:row:/.test(id) || id === 'chk:schema:value';
|
|
38
|
+
|
|
39
|
+
const pagePaths = (t) => [...new Set((String(t).match(/(?<![\w.])\/[\w%.-]+(?:\/[\w%.-]+)*\/?/g) || []).filter((p) => p.length > 1))];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @returns {{ ok: boolean, problems: { rule: number, id: string, why: string, text: string }[] }}
|
|
43
|
+
*/
|
|
44
|
+
export function presentationCheck(audit, { lang = audit.meta?.lang || 'en' } = {}) {
|
|
45
|
+
const ru = lang === 'ru';
|
|
46
|
+
const problems = [];
|
|
47
|
+
const add = (rule, id, why, text = '') => problems.push({ rule, id, why, text: String(text).slice(0, 200) });
|
|
48
|
+
const st = statementsOf(audit, { lang });
|
|
49
|
+
const tasks = agentPrompts(audit, { lang });
|
|
50
|
+
|
|
51
|
+
// 1. Пустой раздел или заголовок без строк.
|
|
52
|
+
for (const [i, ph] of (audit.roadmap || []).entries()) if (!(ph.items || []).filter((x) => String(x).trim()).length) add(1, `road:${i}`, `пустой раздел плана «${ph.badge}»`);
|
|
53
|
+
if (!(audit.summary?.priorities || []).filter((x) => String(x).trim()).length) add(1, 'sum:prio', 'нет ни одного приоритета');
|
|
54
|
+
for (const t of tasks) if (!/\S/.test(String(t.text).replace(/\*\*[^*]+\*\*/g, '').replace(/[#\s]/g, ''))) add(1, `task:${t.id}`, 'пустая задача');
|
|
55
|
+
|
|
56
|
+
// 2. Служебные значения. 3. Оборванная фраза. 8. Тире в наших фразах.
|
|
57
|
+
const texts = [...st.map((s) => ({ id: s.id, text: s.text })), ...tasks.map((t) => ({ id: `task:${t.id}`, text: t.text }))];
|
|
58
|
+
for (const { id, text } of texts) {
|
|
59
|
+
// Команды для разработчика (curl, текст в обратных кавычках) не наш текст для владельца: «/dev/null» там на месте.
|
|
60
|
+
const own = String(text).replace(/`[^`]*`/g, ' ').replace(/\bcurl\b[^)\n]*/g, ' ').replace(QUOTED, ' ');
|
|
61
|
+
for (const [re, why] of SERVICE) if (re.test(own)) add(2, id, why, text);
|
|
62
|
+
for (const line of String(text).split('\n')) { const l = line.replace(/\*\*/g, '').trim(); if (l && DANGLING.test(l)) add(3, id, 'фраза оборвана на предлоге или союзе', l); }
|
|
63
|
+
if (!SITE_TEXT(id) && /[—–]/.test(own)) add(8, id, 'длинное или среднее тире в нашем тексте', text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 4. Статус противоречит числу. 7. Число в задаче не совпадает с отчётом.
|
|
67
|
+
const shown = ru ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
|
|
68
|
+
for (const c of shown) {
|
|
69
|
+
if (!GOOD_SHARE.has(c.id) && !PROBLEM_SHARE.has(c.id)) continue;
|
|
70
|
+
const m = String(c.value || '').match(/(\d+) (?:из|of) (\d+)/);
|
|
71
|
+
if (!m) continue;
|
|
72
|
+
let [x, n] = [Number(m[1]), Number(m[2])];
|
|
73
|
+
// Английская строка «15 of 20 carry three or more H2» считает хорошие страницы: проблемных n - x.
|
|
74
|
+
if (PROBLEM_SHARE.has(c.id) && /three or more|три и более|open with|есть на всех/i.test(String(c.value))) x = n - x;
|
|
75
|
+
if (GOOD_SHARE.has(c.id) && c.status === 'ok' && n && x / n < STATUS_OK_SHARE && x < STATUS_OK_MIN) add(4, `chk:${c.id}`, `«Норма» при ${x} из ${n}`, `${c.label}: ${c.value}`);
|
|
76
|
+
const t = tasks.find((k) => k.id === c.id);
|
|
77
|
+
if (t) {
|
|
78
|
+
const listed = pagePaths(String(t.text).split(/\*\*(?:Как проверить|How to check):\*\*/)[0].replace(/\*\*(?:Сейчас|Now):\*\*[^\n]*/, ''));
|
|
79
|
+
const need = PROBLEM_SHARE.has(c.id) ? x : n - x;
|
|
80
|
+
if (listed.length && listed.length < Math.min(need, 5) && !/вошли только|вошли страницы|only the long|остальные|the others|есть только на|is on only/i.test(t.text)) add(7, `task:${c.id}`, `в отчёте ${need} страниц, в задаче названо ${listed.length}, правило отбора не объяснено`, t.text);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 5. Отрицательное название в сильных сторонах.
|
|
85
|
+
for (const [i, x] of (audit.content?.strengths || []).entries()) { const label = String(x).split(':')[0]; if (NEGATIVE_LABEL.test(label)) add(5, `cont:str:${i}`, `в сильных сторонах название с отрицательным смыслом: «${label}»`, x); }
|
|
86
|
+
|
|
87
|
+
// 6. «Не измеряли» там, где данные есть.
|
|
88
|
+
const ov = audit.overview?.rows || [];
|
|
89
|
+
const hasCheck = (id) => (audit.checks || []).some((c) => c.id === id && c.value);
|
|
90
|
+
for (const [i, [k, v]] of ov.entries()) {
|
|
91
|
+
if (!/не измеряли|not measured/i.test(String(v))) continue;
|
|
92
|
+
if ((/движок|cms|engine/i.test(k) && hasCheck('cms')) || (/карт|sitemap/i.test(k) && hasCheck('sitemap')) || (/язык|language/i.test(k) && audit.meta?.language)) add(6, `ov:row:${i}`, `«не измеряли» в строке «${k}», хотя данные собраны`);
|
|
93
|
+
}
|
|
94
|
+
return { ok: problems.length === 0, problems };
|
|
95
|
+
}
|