@operstack/audit 0.35.1 → 0.36.2
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 +2 -2
- package/src/collect.mjs +73 -17
- package/src/etalon.mjs +1 -1
- package/src/fix.mjs +1 -1
- package/src/i18n.mjs +36 -23
- package/src/index.mjs +1 -0
- package/src/narrative.mjs +78 -31
- package/src/pagekind.mjs +6 -0
- package/src/presentation.mjs +95 -0
- package/src/prompts.mjs +56 -15
- package/src/render.mjs +41 -25
- package/src/rendered.mjs +8 -3
- package/src/tasks-pdf.mjs +1 -1
- package/src/test-answer-first.mjs +27 -0
- package/src/test-demand.mjs +3 -3
- package/src/test-i18n.mjs +3 -3
- package/src/test-narrative.mjs +1 -1
- package/src/test-overall.mjs +1 -1
- package/src/test-presentation.mjs +67 -0
- package/src/test-rendered.mjs +11 -0
- package/src/test-shopify-store.mjs +1 -1
- package/src/verify.mjs +19 -12
package/src/render.mjs
CHANGED
|
@@ -24,9 +24,9 @@ const LABELS = {
|
|
|
24
24
|
eyebrowAudit: 'Digital marketing audit', coverTitle: 'SEO, AEO and GEO<br>audit report',
|
|
25
25
|
auditSubject: 'Audit subject', reportDate: 'Report date', auditType: 'Audit type', preparedBy: 'Prepared by',
|
|
26
26
|
reproducible: 'Every status in this report was read from the public site and can be reproduced from it as it stood on the report date.',
|
|
27
|
-
collectedBy: (tool, date) => `Public signals were collected by ${tool} on ${date}. Every status in this report can be reproduced from the site as it stood on that day.`,
|
|
27
|
+
collectedBy: (tool, date) => `Public signals were collected by ${String(tool).replace(/^@operstack\/audit\s*/, 'the OperStack checker, version ')} on ${date}. Every status in this report can be reproduced from the site as it stood on that day.`,
|
|
28
28
|
secSummary: 'Executive summary', summaryEyebrow: '01 · Summary', keyTakeaway: 'Key takeaway:', threePriorities: 'Three priorities',
|
|
29
|
-
scorecardFoot: 'These six scores are not a breakdown of the number above and will not add up to it: they count the checks in this report, which go wider than AI visibility. Each score counts the checks in this report: a check that passes scores one, a check that needs attention a half, a failing check nothing. An area marked <strong>not measured</strong> is an area this audit does not test, and is never scored on an impression. Every figure here can be recomputed from
|
|
29
|
+
scorecardFoot: 'These six scores are not a breakdown of the number above and will not add up to it: they count the checks in this report, which go wider than AI visibility. Each score counts the checks in this report: a check that passes scores one, a check that needs attention a half, a failing check nothing. An area marked <strong>not measured</strong> is an area this audit does not test, and is never scored on an impression. Every figure here can be recomputed from the check tables below.',
|
|
30
30
|
notMeasured: 'not measured',
|
|
31
31
|
overallLabel: 'AI visibility score',
|
|
32
32
|
grades: { A: 'Strong', B: 'Workable', C: 'Weak', D: 'Poor', E: 'Critical' },
|
|
@@ -38,7 +38,7 @@ const LABELS = {
|
|
|
38
38
|
const read = o.basis ? `It was read from ${o.basis.pages} page${o.basis.pages === 1 ? '' : 's'} of the site${o.basis.sitemapUnchecked ? ', and the sitemap did not answer in time, which is not counted against the score either way' : o.basis.sitemapRead ? ' and its sitemap' : ''} for this score; the rest of the report counts the whole sample. ` : '';
|
|
39
39
|
const where = o.source === 'visibility:reused'
|
|
40
40
|
? `This is the same figure you already saw on the free check${o.measuredAt ? ` on ${String(o.measuredAt).slice(0, 10)}` : ''}. It is not measured again here, so the page, the email and this report always carry one number. `
|
|
41
|
-
: 'Measured here by the same code and the same settings as the free check at oper
|
|
41
|
+
: 'Measured here by the same code and the same settings as the free check at oper‑stack.com/ai‑visibility. ';
|
|
42
42
|
const areas = Array.isArray(o.areas) ? o.areas : [];
|
|
43
43
|
const measured = areas.filter((a) => a.measured !== false && a.score !== null);
|
|
44
44
|
const sumUp = areas.length && measured.length < areas.length
|
|
@@ -60,10 +60,10 @@ const LABELS = {
|
|
|
60
60
|
secTechnical: 'Technical SEO', technicalEyebrow: '04 · Technical', technicalTitle: 'Technical and on-page checklist', colCheck: 'Check', colStatus: 'Status', colFinding: 'Finding',
|
|
61
61
|
secContent: 'Content and on-page', contentEyebrow: '05 · Content', contentTitle: 'Content and on-page SEO', strengths: 'Strengths', weaknesses: 'Weaknesses', gaps: 'Structural gaps: pages that do not exist yet',
|
|
62
62
|
secAeo: 'AEO and GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Answer engines: AEO', aeoLead: 'Whether the site\'s answers can be lifted into "People also ask", AI overviews, ChatGPT and Perplexity.', aeoWorks: 'What already works', aeoBlocks: 'What blocks the wins', recommendation: 'Recommendation:', geoTitle: 'Generative engines: GEO', colSignal: 'Signal', colDetail: 'Detail',
|
|
63
|
-
secOffpage: 'Off-page
|
|
63
|
+
secOffpage: 'Off-page and conversion', offpageEyebrow: '07 · Off-page', offpageTitle: 'Off-page and trust', conversionEyebrow: '08 · Conversion', conversionTitle: 'Conversion and UX', colElement: 'Element', limitationsEyebrow: '10 · Limitations', sourcesTitle: 'Where the data comes from', colSource: 'Source', colAccess: 'Access', colWhatRead: 'What was read',
|
|
64
64
|
sourcesFoot: 'Every score in this report is computed from sources marked free. Nothing that costs money is counted in a score, so you can re-run this audit yourself and get the same or very close numbers.',
|
|
65
65
|
limitations: 'Limitations',
|
|
66
|
-
secRoadmap: 'Roadmap', roadmapEyebrow: '09 · Roadmap', demandEyebrow: '11 · Demand', demandTitle: 'Demand map', secDemand: 'Demand', roadmapTitle: 'What to do, in order', keyMessage: 'Key message:',
|
|
66
|
+
secRoadmap: 'Roadmap and limitations', roadmapEyebrow: '09 · Roadmap', demandEyebrow: '11 · Demand', demandTitle: 'Demand map', secDemand: 'Demand', roadmapTitle: 'What to do, in order', keyMessage: 'Key message:',
|
|
67
67
|
runningHead: 'SEO, AEO and GEO audit',
|
|
68
68
|
statusOk: '✓ OK', statusWarn: '△ Partial', statusBad: '✗ Problem', statusNa: '· Note',
|
|
69
69
|
areas: null,
|
|
@@ -74,9 +74,9 @@ const LABELS = {
|
|
|
74
74
|
eyebrowAudit: 'Аудит цифрового маркетинга', coverTitle: 'Аудит сайта:<br>SEO, AEO и GEO',
|
|
75
75
|
auditSubject: 'Что проверяли', reportDate: 'Дата отчёта', auditType: 'Тип аудита', preparedBy: 'Кто готовил',
|
|
76
76
|
reproducible: 'Каждый статус в этом отчёте прочитан с публичного сайта, и его можно перепроверить по сайту на дату отчёта.',
|
|
77
|
-
collectedBy: (tool, date) => `Публичные сигналы собраны инструментом ${tool
|
|
77
|
+
collectedBy: (tool, date) => `Публичные сигналы собраны ${date ? `${date} ` : ''}инструментом ${String(tool).replace(/^@operstack\/audit\s*/, 'проверки OperStack, версия ')}. Любой статус в этом отчёте можно перепроверить по сайту в том виде, в каком он был в этот день.`,
|
|
78
78
|
secSummary: 'Главное', summaryEyebrow: '01 · Итог', keyTakeaway: 'Главный вывод:', threePriorities: 'Три приоритета',
|
|
79
|
-
scorecardFoot: 'Эти шесть оценок не разбивка балла выше и в сумме его не дают: они считают проверки самого отчёта, а он шире, чем видимость в ИИ. Каждая оценка считает проверки из этого же отчёта: пройденная проверка
|
|
79
|
+
scorecardFoot: 'Эти шесть оценок не разбивка балла выше и в сумме его не дают: они считают проверки самого отчёта, а он шире, чем видимость в ИИ. Каждая оценка считает проверки из этого же отчёта: пройденная проверка даёт балл, частичная половину балла, проваленная ноль. Область с пометкой <strong>не измерялось</strong> это область, которую аудит не проверяет, и она никогда не оценивается на глаз. Любую цифру отсюда можно пересчитать по таблицам проверок ниже.',
|
|
80
80
|
notMeasured: 'не измерялось',
|
|
81
81
|
overallLabel: 'Балл видимости в ИИ',
|
|
82
82
|
grades: { A: 'Сильно', B: 'Рабочее состояние', C: 'Слабо', D: 'Плохо', E: 'Критично' },
|
|
@@ -86,13 +86,13 @@ const LABELS = {
|
|
|
86
86
|
const read = o.basis ? `Прочитано ${p} ${pl} сайта${o.basis.sitemapUnchecked ? ', а карта сайта не ответила вовремя, и это не зачтено ни в плюс, ни в минус' : o.basis.sitemapRead ? ' и его карта' : ''}: это для балла; остальные разделы отчёта считаются по всей выборке. ` : '';
|
|
87
87
|
const where = o.source === 'visibility:reused'
|
|
88
88
|
? `Это ровно то число, которое вы уже видели в бесплатной проверке${o.measuredAt ? ` ${String(o.measuredAt).slice(0, 10)}` : ''}. Здесь оно не меряется заново, поэтому страница, письмо и этот отчёт всегда несут одно число. `
|
|
89
|
-
: 'Измерено здесь тем же кодом и с теми же настройками, что у бесплатной проверки на oper
|
|
89
|
+
: 'Измерено здесь тем же кодом и с теми же настройками, что у бесплатной проверки на oper‑stack.ru/ai‑visibility. ';
|
|
90
90
|
const areas = Array.isArray(o.areas) ? o.areas : [];
|
|
91
91
|
const measured = areas.filter((a) => a.measured !== false && a.score !== null);
|
|
92
92
|
const sumUp = areas.length && measured.length < areas.length
|
|
93
93
|
? `Из пяти областей ниже измерены ${measured.length}, они дают ${measured.reduce((t, a) => t + a.score, 0)} из ${measured.reduce((t, a) => t + a.max, 0)}, и балл это та же доля, перенесённая на сто. Область с пометкой «не измерялось» не зачтена ни в плюс, ни в минус. `
|
|
94
94
|
: 'Пять областей ниже дают в сумме ровно этот балл, его можно сложить руками. ';
|
|
95
|
-
return `${where}${read}${sumUp}Повторная проверка может
|
|
95
|
+
return `${where}${read}${sumUp}Повторная проверка может отличаться на пункт-другой: проверка, которая успела ответить в один прогон, может не успеть в следующий, а балл никогда не засчитывает сайту в минус то, что не удалось прочитать.`;
|
|
96
96
|
},
|
|
97
97
|
secondMeasure: 'Второе, отдельное измерение: как этот отчёт оценивает собственные проверки',
|
|
98
98
|
ofHundred: 'из 100', ofHundredShort: 'ИЗ 100', aiVisibility: 'ИИ-видимость', seoBasics: 'Основы SEO',
|
|
@@ -106,12 +106,12 @@ const LABELS = {
|
|
|
106
106
|
secOverview: 'О сайте', overviewEyebrow: '02 · Обзор', whatSiteIs: 'Что это за сайт', parameter: 'Параметр', value: 'Значение', pagesSampled: 'Проверенные страницы', colUrl: 'Адрес', colTitle: 'Заголовок', colWords: 'Слов',
|
|
107
107
|
secCritical: 'Критичное', criticalEyebrow: '03 · Срочно', criticalTitle: 'Что чинить первым', whatItCosts: 'Чем это грозит', theFix: 'Как чинится', noBody: 'У этой проблемы нет описания в файле аудита.', noCritical: 'Критичных дефектов в публичных сигналах не найдено.',
|
|
108
108
|
secTechnical: 'Техническое SEO', technicalEyebrow: '04 · Техника', technicalTitle: 'Техническая проверка и страницы', colCheck: 'Проверка', colStatus: 'Статус', colFinding: 'Что нашли',
|
|
109
|
-
secContent: 'Контент и страницы', contentEyebrow: '05 · Контент', contentTitle: 'Контент и внутренняя оптимизация', strengths: 'Сильные стороны', weaknesses: 'Слабые места', gaps: 'Структурные дыры:
|
|
110
|
-
secAeo: 'AEO и GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Ответные системы: AEO', aeoLead: '
|
|
111
|
-
secOffpage: '
|
|
109
|
+
secContent: 'Контент и страницы', contentEyebrow: '05 · Контент', contentTitle: 'Контент и внутренняя оптимизация', strengths: 'Сильные стороны', weaknesses: 'Слабые места', gaps: 'Структурные дыры: страницы, которых пока нет',
|
|
110
|
+
secAeo: 'AEO и GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Ответные системы: AEO', aeoLead: 'Здесь видно, может ли ответ с вашего сайта попасть в блок «Люди также спрашивают» и в ответы ИИ: ChatGPT, Perplexity, Алисы.', aeoWorks: 'Что уже работает', aeoBlocks: 'Что мешает', recommendation: 'Рекомендация:', geoTitle: 'Генеративные системы: GEO', colSignal: 'Сигнал', colDetail: 'Подробности',
|
|
111
|
+
secOffpage: 'Ссылки и конверсия', offpageEyebrow: '07 · Ссылки', offpageTitle: 'Ссылки и доверие', conversionEyebrow: '08 · Конверсия', conversionTitle: 'Конверсия и удобство', colElement: 'Элемент', limitationsEyebrow: '10 · Ограничения', sourcesTitle: 'Откуда взяты данные', colSource: 'Источник', colAccess: 'Доступ', colWhatRead: 'Что прочитали',
|
|
112
112
|
sourcesFoot: 'Каждая оценка в этом отчёте посчитана из источников с пометкой «бесплатно». Ничего платного ни в одну оценку не входит, поэтому вы можете повторить этот аудит сами и получить те же или очень близкие цифры.',
|
|
113
113
|
limitations: 'Ограничения',
|
|
114
|
-
secRoadmap: 'План работ', roadmapEyebrow: '09 · План', demandEyebrow: '11 · Спрос', demandTitle: 'Карта спроса', secDemand: 'Спрос', roadmapTitle: 'Что делать и в каком порядке', keyMessage: 'Главное сообщение:',
|
|
114
|
+
secRoadmap: 'План работ и ограничения', roadmapEyebrow: '09 · План', demandEyebrow: '11 · Спрос', demandTitle: 'Карта спроса', secDemand: 'Спрос', roadmapTitle: 'Что делать и в каком порядке', keyMessage: 'Главное сообщение:',
|
|
115
115
|
runningHead: 'Аудит SEO, AEO и GEO',
|
|
116
116
|
statusOk: '✓ Норма', statusWarn: '△ Частично', statusBad: '✗ Проблема', statusNa: '· Заметка',
|
|
117
117
|
areas: AREAS_RU,
|
|
@@ -314,7 +314,7 @@ function css() {
|
|
|
314
314
|
table.pages.appendix td:nth-child(1), table.pages.appendix th:nth-child(1) { width: 33%; }
|
|
315
315
|
table.pages.appendix td:nth-child(2), table.pages.appendix th:nth-child(2) { width: 35%; }
|
|
316
316
|
table.pages.appendix td:nth-child(n+3), table.pages.appendix th:nth-child(n+3) { width: 8%; text-align: right; font-variant-numeric: tabular-nums; }
|
|
317
|
-
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .status-na { color: var(--muted); }
|
|
317
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .status-na { color: var(--muted); white-space: nowrap; }
|
|
318
318
|
.cards { display: grid; gap: 3mm; margin: 4mm 0; }
|
|
319
319
|
.card { border-left: 3px solid var(--danger); background: #fbf1f0; padding: 3.5mm 4mm; border-radius: 0 4px 4px 0; }
|
|
320
320
|
.card.warn { border-left-color: var(--warn); background: #fdf8ee; }
|
|
@@ -335,7 +335,7 @@ function css() {
|
|
|
335
335
|
|
|
336
336
|
const header = (audit, section, L) => `<div class="page-header"><strong>${esc(audit.client.name)}</strong><span>${esc(L.runningHead)} · ${esc(section)}</span></div>`;
|
|
337
337
|
const footer = (audit, n, by) => `<div class="page-footer"><span>${esc(by || audit.client.preparedBy || 'OperStack')} · ${esc(audit.client.reportDate)}</span><span>${n}</span></div>`;
|
|
338
|
-
const list = (items) => `<ul>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ul>`;
|
|
338
|
+
const list = (items) => `<ul>${(items || []).filter((i) => String(i ?? '').trim()).map((i) => `<li>${t(i)}</li>`).join('')}</ul>`;
|
|
339
339
|
const olist = (items) => `<ol>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ol>`;
|
|
340
340
|
const statusCell = (s, L) => `<td class="${STATUS_CLASS[s] || STATUS_CLASS.na}">${L[STATUS_KEYS[s] || 'statusNa']}</td>`;
|
|
341
341
|
|
|
@@ -455,7 +455,10 @@ export function toHtml(audit, brand = null) {
|
|
|
455
455
|
* второй про проверки самого отчёта и что делать. Оба полные.
|
|
456
456
|
*/
|
|
457
457
|
pages.push(`<div class="page">${masthead}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p>${overallBlock}${footer(a, n++, preparedBy)}</div>`);
|
|
458
|
-
|
|
458
|
+
// Фраза про «не измерялось» только если такая область в отчёте есть: иначе владелец ищет пометку, которой нет (cimes.ru, 14.10.2026).
|
|
459
|
+
const anyUnmeasured = Object.values(a.scores || {}).some((v) => typeof v !== 'number');
|
|
460
|
+
const scoreFoot = anyUnmeasured ? L.scorecardFoot : L.scorecardFoot.replace(/(Область с пометкой|An area marked) <strong>[^<]+<\/strong>[^.]*\. ?/, '');
|
|
461
|
+
pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secondMeasure}</h2><div class="scorecard">${scoreCards}</div><p class="scorecard-foot">${scoreFoot}</p><div class="verdict"><p><strong>${L.keyTakeaway}</strong> ${t(a.summary.verdict)}</p></div><h3>${L.threePriorities}</h3>${olist(a.summary.priorities)}${footer(a, n++, preparedBy)}</div>`);
|
|
459
462
|
/*
|
|
460
463
|
* Обзор: таблица прочитанных страниц не влезала на лист и переливалась. Режем её по счёту
|
|
461
464
|
* строк, как техническую. Первый лист несёт параметры сайта и первые строки, дальше только
|
|
@@ -496,7 +499,8 @@ export function toHtml(audit, brand = null) {
|
|
|
496
499
|
pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${L.overviewEyebrow}</div><h2>${L.pagesSampled}</h2><table class="pages">${pagesHead}${chunk.map(pageRow).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
|
|
497
500
|
}
|
|
498
501
|
const cardBody = (c) => [
|
|
499
|
-
|
|
502
|
+
// Текст карточки это отдельное предложение под заголовком: с заглавной (cimes.ru, 14.10.2026).
|
|
503
|
+
c.text ? `<p>${t(`${String(c.text)[0].toUpperCase()}${String(c.text).slice(1)}`)}</p>` : '',
|
|
500
504
|
c.cost ? `<p><span class="card-tag">${L.whatItCosts}</span>${t(c.cost)}</p>` : '',
|
|
501
505
|
c.fix ? `<p><span class="card-tag">${L.theFix}</span>${t(c.fix)}</p>` : '',
|
|
502
506
|
].join('') || '<p class="card-empty">${L.noBody}</p>';
|
|
@@ -506,8 +510,11 @@ export function toHtml(audit, brand = null) {
|
|
|
506
510
|
* он занимал целую страницу одной строкой «критичных дефектов не найдено», а это в отчёте за
|
|
507
511
|
* деньги выглядит как пустой лист. Сам факт не пропадает: он сказан в сводке.
|
|
508
512
|
*/
|
|
513
|
+
let criticalInline = '';
|
|
509
514
|
if ((a.critical || []).length) {
|
|
510
|
-
|
|
515
|
+
// Один-два пункта на целом листе выглядят пустым листом (cimes.ru, 14.10.2026): тогда раздел встаёт в начало технической проверки.
|
|
516
|
+
if (a.critical.length <= 3) criticalInline = `<div class="eyebrow">${L.criticalEyebrow}</div><h2>${L.criticalTitle}</h2><div class="cards">${cards}</div>`;
|
|
517
|
+
else pages.push(`<div class="page">${header(a, L.secCritical, L)}<div class="eyebrow">${L.criticalEyebrow}</div><h2>${L.criticalTitle}</h2><div class="cards">${cards}</div>${footer(a, n++, preparedBy)}</div>`);
|
|
511
518
|
}
|
|
512
519
|
/*
|
|
513
520
|
* Длинная таблица режется на листы по счёту строк, а не переливается сама.
|
|
@@ -524,15 +531,18 @@ export function toHtml(audit, brand = null) {
|
|
|
524
531
|
const techPages = [];
|
|
525
532
|
let bucket = [];
|
|
526
533
|
let units = 0;
|
|
534
|
+
if (criticalInline) units = 3 * a.critical.length;
|
|
527
535
|
for (const c of techChecks) {
|
|
528
536
|
const cost = c.comment ? 1.5 : 1;
|
|
529
537
|
if (units + cost > TECH_UNITS_PER_PAGE && bucket.length) { techPages.push(bucket); bucket = []; units = 0; }
|
|
530
538
|
bucket.push(c); units += cost;
|
|
531
539
|
}
|
|
532
540
|
if (bucket.length) techPages.push(bucket);
|
|
541
|
+
// Хвост в одну-две строки не печатаем отдельным листом: он уходит на предыдущий, подгонка листа его ужмёт (cimes.ru, 14.10.2026).
|
|
542
|
+
if (techPages.length > 1 && techPages[techPages.length - 1].length <= 2) { const tail = techPages.pop(); techPages[techPages.length - 1].push(...tail); }
|
|
533
543
|
techPages.forEach((chunk, i) => {
|
|
534
544
|
const title = techPages.length > 1 ? `${L.technicalTitle} (${i + 1}/${techPages.length})` : L.technicalTitle;
|
|
535
|
-
pages.push(`<div class="page">${header(a, L.secTechnical, L)}<div class="eyebrow">${L.technicalEyebrow}</div><h2>${esc(title)}</h2><table>${techHead}${chunk.map(rowHtml).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
|
|
545
|
+
pages.push(`<div class="page">${header(a, i === 0 && criticalInline ? `${L.secCritical} · ${L.secTechnical}` : L.secTechnical, L)}${i === 0 ? criticalInline : ''}<div class="eyebrow"${i === 0 && criticalInline ? ' style="margin-top:7mm"' : ''}>${L.technicalEyebrow}</div><h2>${esc(title)}</h2><table>${techHead}${chunk.map(rowHtml).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
|
|
536
546
|
});
|
|
537
547
|
pages.push(`<div class="page">${header(a, L.secContent, L)}<div class="eyebrow">${L.contentEyebrow}</div><h2>${L.contentTitle}</h2><div class="two-col"><div><h3>${L.strengths}</h3>${list(a.content.strengths)}</div><div><h3>${L.weaknesses}</h3>${list(a.content.weaknesses)}</div></div><h3>${L.gaps}</h3>${list(a.content.gaps)}${checksBy('content').length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy('content').map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}</td></tr>`).join('')}</table>` : ''}${footer(a, n++, preparedBy)}</div>`);
|
|
538
548
|
const geoRows = [...checksBy('aeo'), ...checksBy('geo')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `. ${esc(c.comment.charAt(0).toUpperCase() + c.comment.slice(1))}` : ''}</td></tr>`).join('') + (a.geo.rows || []).map(([k, s, d]) => `<tr><td>${t(k)}</td><td>${t(s)}</td><td>${t(d)}</td></tr>`).join('');
|
|
@@ -542,7 +552,7 @@ export function toHtml(audit, brand = null) {
|
|
|
542
552
|
* AEO и GEO стояли на одном листе: два заголовка, два списка, две врезки и таблица сигналов.
|
|
543
553
|
* Лист переливал почти на восемьсот пикселей. Теперь по разделу на лист.
|
|
544
554
|
*/
|
|
545
|
-
pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${L.aeoTitle}</h2><p class="lead">${esc(L.aeoLead)}</p><div class="two-col"><div><h3>${L.aeoWorks}</h3>${list(a.aeo.works)}</div><div><h3>${L.aeoBlocks}</h3>${list(a.aeo.blocks)}</div></div><div class="callout"><p><strong>${L.recommendation}</strong> ${t(a.aeo.recommendation)}</p></div>${footer(a, n++, preparedBy)}</div>`);
|
|
555
|
+
pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${L.aeoTitle}</h2><p class="lead">${esc(L.aeoLead)}</p><div class="two-col"><div><h3>${L.aeoWorks}</h3>${list(a.aeo.works)}</div><div><h3>${L.aeoBlocks}</h3>${list(a.aeo.blocks)}</div></div><div class="callout"><p><strong>${L.recommendation}</strong> ${t(a.aeo.recommendation)}</p></div>${checkTable('aeo')}${footer(a, n++, preparedBy)}</div>`);
|
|
546
556
|
/*
|
|
547
557
|
* Таблица сигналов GEO выросла до двадцати пяти строк и на лист не влезает. Режем её, как и
|
|
548
558
|
* остальные длинные таблицы: по счёту строк, с продолжением под своим номером.
|
|
@@ -558,7 +568,9 @@ export function toHtml(audit, brand = null) {
|
|
|
558
568
|
pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${esc(title)}</h2><table>${geoHead}${chunk.join('')}</table>${tail}${footer(a, n++, preparedBy)}</div>`);
|
|
559
569
|
});
|
|
560
570
|
pages.push(`<div class="page">${header(a, L.secOffpage, L)}<div class="eyebrow">${L.offpageEyebrow}</div><h2>${L.offpageTitle}</h2>${checkTable('offpage')}${list(a.offpage.listed)}<p>${t(a.offpage.note)}</p>${footer(a, n++, preparedBy)}</div>`);
|
|
561
|
-
|
|
571
|
+
// Строки, которые слово в слово повторяют таблицу проверок выше, не печатаем второй раз (cimes.ru, 14.10.2026).
|
|
572
|
+
const convRows = (a.conversion.rows || []).filter(([, v]) => !checksBy('conversion').some((c) => String(c.value || '').trim() === String(v || '').trim()));
|
|
573
|
+
pages.push(`<div class="page">${header(a, L.secOffpage, L)}<div class="eyebrow">${L.conversionEyebrow}</div><h2>${L.conversionTitle}</h2>${checkTable('conversion')}${convRows.length ? `<table><tr><th>${L.colElement}</th><th>${L.colFinding}</th></tr>${convRows.map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table>` : ''}${footer(a, n++, preparedBy)}</div>`);
|
|
562
574
|
|
|
563
575
|
/*
|
|
564
576
|
* Вы и ваши конкуренты. Балл видимости стоит первым, потому что он у всех посчитан по десяти
|
|
@@ -679,6 +691,10 @@ export function toHtml(audit, brand = null) {
|
|
|
679
691
|
if (a.demand && Array.isArray(a.demand.queries)) {
|
|
680
692
|
pages.push(`<div class="page">${header(a, L.secDemand, L)}<div class="eyebrow">${L.demandEyebrow}</div><h2>${L.demandTitle}</h2>${renderDemandHtml(a.demand, { lang: a.meta?.lang === 'ru' ? 'ru' : 'en' })}${footer(a, n++, preparedBy)}</div>`);
|
|
681
693
|
}
|
|
694
|
+
// Номера разделов сквозные: без раздела «Что чинить первым» после 02 шёл 04 (cimes.ru, 14.10.2026).
|
|
695
|
+
{ const order = []; for (const m of pages.join('').matchAll(/class="eyebrow">(\d{2}) ·/g)) if (!order.includes(m[1])) order.push(m[1]);
|
|
696
|
+
const map = Object.fromEntries(order.map((n, i) => [n, String(i + 1).padStart(2, '0')]));
|
|
697
|
+
for (let i = 0; i < pages.length; i++) pages[i] = pages[i].replace(/class="eyebrow">(\d{2}) ·/g, (m, n) => `class="eyebrow">${map[n] || n} ·`); }
|
|
682
698
|
return `<!doctype html><html lang="${a.meta?.lang === 'ru' ? 'ru' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: ${esc(L.docTitle)}</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono&display=swap"><style>${css()}${accentCss}</style></head><body>${pages.join('\n')}${FIT_SCRIPT}</body></html>`;
|
|
683
699
|
}
|
|
684
700
|
|
|
@@ -691,12 +707,12 @@ export function toHtml(audit, brand = null) {
|
|
|
691
707
|
* живёт в HTML, а не в печатающем коде. Проверка числа листов после печати: pdfPageCount.
|
|
692
708
|
*/
|
|
693
709
|
export const FIT_SCRIPT = `<script>(document.fonts?document.fonts.ready:Promise.resolve()).then(function(){var H=297*96/25.4;function rowsOf(t){return Array.prototype.filter.call(t.querySelectorAll('tr'),function(r){return !r.querySelector('th')})}
|
|
694
|
-
for(var g=0;g<30;g++){var did=false,pages=document.querySelectorAll('.page');for(var i=0;i<pages.length;i++){var el=pages[i];if(
|
|
695
|
-
var clone=el.cloneNode(true),ctbs=clone.querySelectorAll('table'),ctb=ctbs[ctbs.length-1],crows=rowsOf(ctb),k=rows.length;while(el.scrollHeight>H+1&&k>1){rows[--k].style.display='none'}if(k===rows.length)continue;
|
|
710
|
+
for(var g=0;g<30;g++){var did=false,pages=document.querySelectorAll('.page');for(var i=0;i<pages.length;i++){var el=pages[i],h0=el.scrollHeight;if(h0<=H+1)continue;var tbs=el.querySelectorAll('table');if(!tbs.length)continue;var tb=tbs[tbs.length-1],rows=rowsOf(tb);if(el.scrollHeight/H<1.06||rows.length<4)continue;
|
|
711
|
+
var clone=el.cloneNode(true),ctbs=clone.querySelectorAll('table'),ctb=ctbs[ctbs.length-1],crows=rowsOf(ctb),k=rows.length;while(el.scrollHeight>H+1&&k>1){rows[--k].style.display='none'}if(k===rows.length)continue;if(rows.length-k<=3&&h0*0.85<=H){for(var r3=k;r3<rows.length;r3++)rows[r3].style.display='';continue}
|
|
696
712
|
for(var j=k;j<rows.length;j++)rows[j].parentNode.removeChild(rows[j]);for(var j2=0;j2<k;j2++)crows[j2].parentNode.removeChild(crows[j2]);
|
|
697
|
-
var keep=[];for(var n=ctb;n&&n!==clone;n=n.parentNode)keep.push(n);Array.prototype.slice.call(clone.children).forEach(function(c){if(keep.indexOf(c)>=0)return;if(/page-footer|masthead|eyebrow/.test(c.className||''))return;if(/^H[1-3]$/.test(c.tagName))return;if(c.compareDocumentPosition(ctb)&4)c.parentNode.removeChild(c)});
|
|
713
|
+
var keep=[];for(var n=ctb;n&&n!==clone;n=n.parentNode)keep.push(n);Array.prototype.slice.call(clone.children).forEach(function(c){if(keep.indexOf(c)>=0)return;if(/page-footer|page-header|masthead|eyebrow/.test(c.className||''))return;if(/^H[1-3]$/.test(c.tagName))return;if(c.compareDocumentPosition(ctb)&4)c.parentNode.removeChild(c)});
|
|
698
714
|
var top=tb;while(top.parentNode&&top.parentNode!==el)top=top.parentNode;var nx=top.nextElementSibling;while(nx){var after=nx.nextElementSibling;if(!/page-footer/.test(nx.className||''))nx.parentNode.removeChild(nx);nx=after}el.parentNode.insertBefore(clone,el.nextSibling);did=true;break}if(!did)break}
|
|
699
|
-
Array.prototype.forEach.call(document.querySelectorAll('.page'),function(el){var h=el.scrollHeight;if(h>H+1){var z=Math.max(0.
|
|
715
|
+
Array.prototype.forEach.call(document.querySelectorAll('.page'),function(el){var h=el.scrollHeight;if(h>H+1){var z=Math.max(0.85,Math.floor((H-2)/h*1000)/1000);el.style.zoom=z;el.style.height=(H/z)+'px';el.style.minHeight=(H/z)+'px';el.style.overflow='hidden';if(z===0.85&&h*z>H+1)document.documentElement.setAttribute('data-overflow','1')}});
|
|
700
716
|
var no=0;Array.prototype.forEach.call(document.querySelectorAll('.page-footer span:last-child'),function(s){if(/^\\d+$/.test(s.textContent.trim()))s.textContent=String(++no)});
|
|
701
717
|
document.documentElement.setAttribute('data-pages',String(document.querySelectorAll('.page').length))});</script>`;
|
|
702
718
|
|
package/src/rendered.mjs
CHANGED
|
@@ -132,9 +132,11 @@ export function renderedDom(url, { chrome = null, timeoutMs = 45000, waitMs = 80
|
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
const CHROME_STRIP = /<(nav|footer|aside|script|style|noscript|template)[\s\S]*?<\/\1>/gi;
|
|
135
|
+
const CHROME_STRIP = /<(nav|header|footer|aside|script|style|noscript|template)[\s\S]*?<\/\1>/gi;
|
|
136
136
|
// Баннер cookie и согласия рисует скрипт, но это не текст страницы: у cimes.ru 14.10.2026 он один дал «скрыто 19 процентов».
|
|
137
|
-
|
|
137
|
+
// Меню и выпадающие списки, которые скрипт достраивает вне <nav>, тоже не текст страницы: у cimes.ru 14.10.2026 «скрыто 20 процентов»
|
|
138
|
+
// были пункты меню «Услуги» и «Готовые решения». Убираем одинаково из обеих версий страницы.
|
|
139
|
+
const COOKIE_BOX = /cookie|consent|gdpr|cmp-|onetrust|cookiebot|(^|[\s_-])(menu|submenu|mega-?menu|dropdown|navbar|burger|mobile-nav|offcanvas)([\s_-]|$)/i;
|
|
138
140
|
const dropCookieBoxes = (html) => {
|
|
139
141
|
try {
|
|
140
142
|
const root = parseHtml(String(html));
|
|
@@ -190,7 +192,10 @@ export function jsBlindnessCheck(pages, lang = 'en') {
|
|
|
190
192
|
const worst = measured.slice().sort((a, b) => b.hiddenShare - a.hiddenShare)[0];
|
|
191
193
|
const median = measured.map((p) => p.hiddenShare).sort((a, b) => a - b)[Math.floor(measured.length / 2)];
|
|
192
194
|
const status = measured.some((p) => p.verdict === 'bad') ? 'bad' : measured.some((p) => p.verdict === 'warn') ? 'warn' : 'ok';
|
|
193
|
-
|
|
195
|
+
// Ничего не скрыто: так и пишем, без «видно всё, кроме 0 процентов» (cimes.ru, 14.10.2026).
|
|
196
|
+
const value = worst.hiddenShare === 0
|
|
197
|
+
? (lang === 'ru' ? `без скриптов виден весь текст: проверено на ${measured.length} ${measured.length === 1 ? 'странице' : 'страницах'}` : `all the text is visible without JavaScript, checked on ${measured.length} page${measured.length === 1 ? '' : 's'}`)
|
|
198
|
+
: lang === 'ru'
|
|
194
199
|
? `без скриптов видно всё, кроме ${median} процентов текста (медиана по ${measured.length} ${measured.length === 1 ? 'странице' : 'страницам'}), хуже всего ${worst.url}: там скрыто ${worst.hiddenShare} процентов, ${WORD.ru(worst.raw)} без скриптов против ${WORD.ru(worst.rendered)} в браузере`
|
|
195
200
|
: `${median}% of the text is invisible without JavaScript (median over ${measured.length} page${measured.length === 1 ? '' : 's'}); worst is ${worst.url} at ${worst.hiddenShare}%, ${worst.raw} words without scripts against ${worst.rendered} in a browser`;
|
|
196
201
|
// Слова по размеру беды: на четырнадцати процентах писать «страница почти пустая» это враньё
|
package/src/tasks-pdf.mjs
CHANGED
|
@@ -20,7 +20,7 @@ export function renderTasksHtml(audit, { lang = audit.meta?.lang || 'en' } = {})
|
|
|
20
20
|
const host = audit.meta?.host || '';
|
|
21
21
|
const L = ru
|
|
22
22
|
? { eb: 'OperStack · задачи для исполнителя', h1: `${host}: что поправить на сайте`, now: 'Сейчас', task: 'Что сделать', verify: 'Как проверить',
|
|
23
|
-
intro: ['Ниже список задач, по одной на каждую найденную проблему. Каждая написана обычными словами,
|
|
23
|
+
intro: ['Ниже список задач, по одной на каждую найденную проблему. Каждая написана обычными словами, её поймёт и разработчик, и помощник вроде ChatGPT.', 'Файл можно переслать исполнителю целиком. Каждая задача идёт вместе со строками «Сейчас» и «Как проверить»: по ним видно, откуда начинать и когда работа сделана.'],
|
|
24
24
|
rule: 'Правило для исполнителя: не выдумывай факты о бизнесе. Всё, чего нет на сайте и чего тебе не сказали, спрашивай у владельца.', foot: 'задачи' }
|
|
25
25
|
: { eb: 'OperStack · tasks for your developer', h1: `${host}: what to fix on the site`, now: 'Now', task: 'What to do', verify: 'How to check',
|
|
26
26
|
intro: ['Below is one task for every problem found, each in plain words, ready to hand to whoever runs your site or to an assistant like ChatGPT.', 'Hand over one task at a time, with its "Now" and "How to check" lines: that is how the developer knows where to start and when it is done.', 'You can forward this whole file to whoever runs your site.'],
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ответ в первом абзаце (15.10.2026, cimes.ru): вводный блок после H1 из нескольких абзацев засчитывается целиком,
|
|
3
|
+
* если вместе он укладывается в 20-90 слов. Длинная статья без подзаголовков по-прежнему мерится первым абзацем.
|
|
4
|
+
*/
|
|
5
|
+
import { analysePage } from './collect.mjs';
|
|
6
|
+
|
|
7
|
+
let bad = 0;
|
|
8
|
+
const is = (n, a, b) => { if (a !== b) { bad++; console.error(`FAIL ${n}: ждали ${b}, вышло ${a}`); } else console.log(`ok ${n}`); };
|
|
9
|
+
const page = (body) => `<html><head><title>T</title></head><body><main>${body}</main></body></html>`;
|
|
10
|
+
|
|
11
|
+
const cimes = page(`<h1>Создание сайта в Новороссийске и любом другом городе России</h1><div class="text">
|
|
12
|
+
<p>Разрабатываем сайты, интернет-магазины и CRM — на 1С-Битрикс и на собственном коде, с интеграциями с 1С, онлайн-оплатой и службами доставки.</p>
|
|
13
|
+
<p class="hero-prices">Визитка — от 28 000 ₽ · корпоративный — от 78 000 ₽ · магазин — от 120 000 ₽. Смета бесплатно за 1–2 рабочих дня.</p></div>
|
|
14
|
+
<div class="buttons"><button class="btn">Обсудить проект</button></div><h2>Услуги</h2><p>${'Текст раздела. '.repeat(40)}</p>`);
|
|
15
|
+
is('cimes: ответ двумя абзацами до кнопок засчитан', analysePage('https://cimes.ru/', cimes).answerFirst, true);
|
|
16
|
+
|
|
17
|
+
const longLead = page(`<h1>Статья</h1><p>${'Слово '.repeat(30)}и 5 причин.</p>${`<p>${'Ещё текст абзаца без цифр. '.repeat(10)}</p>`.repeat(4)}`);
|
|
18
|
+
is('длинный вводный блок: мерится первый абзац', analysePage('https://x.ru/a/', longLead).answerFirst, true);
|
|
19
|
+
|
|
20
|
+
const shortNoFigure = page(`<h1>Услуга</h1><p>Делаем сайты под ключ для бизнеса.</p><button>Заказать</button><p>${'Описание без цифр. '.repeat(20)}</p>`);
|
|
21
|
+
is('короткий абзац до кнопки без цифры: ответа нет', analysePage('https://x.ru/b/', shortNoFigure).answerFirst, false);
|
|
22
|
+
|
|
23
|
+
const techWords = page(`<h1>Дизайн сайтов</h1><p>Делаем дизайн и вёрстку сайтов на HTML5 и CSS3 с адаптивом под телефоны и планшеты, наполняем страницы текстами и фотографиями для бизнеса.</p><button>Заказать</button>`);
|
|
24
|
+
is('HTML5 и CSS3 не цифра ответа', analysePage('https://x.ru/c/', techWords).answerFirst, false);
|
|
25
|
+
|
|
26
|
+
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
27
|
+
console.log('\nответ в первом абзаце: все случаи сходятся');
|
package/src/test-demand.mjs
CHANGED
|
@@ -230,13 +230,13 @@ function fakeTopvisor() {
|
|
|
230
230
|
fixture.totals = demandTotals(fixture.queries);
|
|
231
231
|
const withMap = { ...sample, demand: fixture };
|
|
232
232
|
const html = toHtml(withMap);
|
|
233
|
-
ok('в отчёте появилась страница «
|
|
233
|
+
ok('в отчёте появилась страница «NN · Demand»', /\d{2} · Demand/.test(html) && /Demand map/.test(html));
|
|
234
234
|
ok('таблица источников называет Topvisor платным и не входящим в балл', /Topvisor \(volumes for the demand map\)/.test(html) && /counts towards no score/.test(html));
|
|
235
|
-
ok('без карты страницы нет',
|
|
235
|
+
ok('без карты страницы нет', !/\d{2} · Demand/.test(toHtml(sample)));
|
|
236
236
|
is('баллы с картой и без неё одинаковы', JSON.stringify(computeScores(withMap.checks || []).scores), before);
|
|
237
237
|
ok('проверка баллов проходит с картой', verifyScores(withMap).ok !== false);
|
|
238
238
|
const ruHtml = toHtml({ ...sample, meta: { ...sample.meta, lang: 'ru' }, demand: { ...fixture, lang: 'ru', regionName: 'США' } });
|
|
239
|
-
ok('русский отчёт: «
|
|
239
|
+
ok('русский отчёт: «NN · Спрос»', /\d{2} · Спрос/.test(ruHtml) && /Карта спроса/.test(ruHtml));
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
package/src/test-i18n.mjs
CHANGED
|
@@ -49,9 +49,9 @@ const lostNumbers = ru.filter((c, i) => {
|
|
|
49
49
|
}).map((c) => `${c.id}: было ${digits(all[all.findIndex((x) => x.id === c.id)].value)}, стало ${digits(c.value)}`);
|
|
50
50
|
is('числа в тексте сохранились', lostNumbers, []);
|
|
51
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
|
|
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
55
|
is('неизмеренное переводится', localiseBasisNote('not measured: no link data was collected'), 'не измерялось: no link data was collected');
|
|
56
56
|
|
|
57
57
|
is('английский язык ничего не меняет', localiseChecks(all, 'en'), all);
|
package/src/test-narrative.mjs
CHANGED
|
@@ -67,7 +67,7 @@ ok('резюме говорит, сколько прочитано, а не ск
|
|
|
67
67
|
const en = draftNarrative(audit(), { lang: 'en' });
|
|
68
68
|
ok('и по-английски тоже', /^We read \d+ pages? of /.test(en.summary.lead) && !/is a site of/.test(en.summary.lead));
|
|
69
69
|
const withLang = audit(); withLang.meta.language = 'en';
|
|
70
|
-
is('язык из кода страницы попадает в обзор', draftNarrative(withLang, { lang: 'en' }).overview.rows[2][1], '
|
|
70
|
+
is('язык из кода страницы попадает в обзор словом', draftNarrative(withLang, { lang: 'en' }).overview.rows[2][1], 'English');
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// Числа сходятся между собой
|
package/src/test-overall.mjs
CHANGED
|
@@ -102,7 +102,7 @@ ok('параметры нельзя поменять на ходу', Object.isFr
|
|
|
102
102
|
const html = toHtml(audit, null);
|
|
103
103
|
is(`${lang}: в отчёте стоит сохранённый балл`, Number((html.match(/class="ring-num"[^>]*>(\d+)</) || [])[1]), 61);
|
|
104
104
|
ok(`${lang}: оценка словом на своём языке`, grade.test((html.match(/overall-grade">([^<]*)</) || [])[1] || ''));
|
|
105
|
-
ok(`${lang}: сказано, что это то же измерение, что на странице`, /ai
|
|
105
|
+
ok(`${lang}: сказано, что это то же измерение, что на странице`, /ai[-\u2011]visibility/.test((html.match(/overall-note">([^<]*)</) || [])[1] || ''));
|
|
106
106
|
ok(`${lang}: пять областей напечатаны рядом с баллом`, /class="areas-bars"/.test(html) && (html.match(/class="ar ar-/g) || []).length === 5);
|
|
107
107
|
ok(`${lang}: балл стоит и пилюлей с буквой`, /class="pill pill-(good|fair|poor)"/.test(html));
|
|
108
108
|
const foot = (html.match(/scorecard-foot">([^<]*)</) || [])[1] || '';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Проверка оформления (presentation.mjs): каждое из восьми правил отдельным тестом на настоящем отчёте
|
|
3
|
+
* эталона, куда вписан один огрех. Плюс все эталоны без огрехов должны проходить чисто.
|
|
4
|
+
*/
|
|
5
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { replayer } from './etalon.mjs';
|
|
7
|
+
import { collect } from './collect.mjs';
|
|
8
|
+
import { secondLook } from './verify.mjs';
|
|
9
|
+
import { draftNarrative } from './narrative.mjs';
|
|
10
|
+
import { presentationCheck } from './presentation.mjs';
|
|
11
|
+
|
|
12
|
+
const dir = new URL('../test/etalon/', import.meta.url);
|
|
13
|
+
let bad = 0;
|
|
14
|
+
const is = (n, c) => { if (!c) { bad++; console.error(`FAIL ${n}`); } else console.log(`ok ${n}`); };
|
|
15
|
+
|
|
16
|
+
async function build(name) {
|
|
17
|
+
const truth = JSON.parse(readFileSync(new URL(`${name}-truth.json`, dir), 'utf8'));
|
|
18
|
+
const real = globalThis.fetch;
|
|
19
|
+
globalThis.fetch = replayer(new URL(`${name}.json.gz`, dir));
|
|
20
|
+
const raw = await collect(truth.site, { pages: 20, tier: '9', lang: truth.lang, rendered: false, log: () => {}, pageDelayMs: 0 });
|
|
21
|
+
await secondLook(raw, { maxPages: 120, probes: false, log: () => {} });
|
|
22
|
+
globalThis.fetch = real;
|
|
23
|
+
return { a: draftNarrative(raw, { lang: truth.lang }), lang: truth.lang };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Все эталоны чистые.
|
|
27
|
+
const names = readdirSync(dir).filter((x) => x.endsWith('-truth.json')).map((x) => x.replace('-truth.json', '')).sort();
|
|
28
|
+
for (const name of names) {
|
|
29
|
+
const { a, lang } = await build(name);
|
|
30
|
+
const r = presentationCheck(a, { lang });
|
|
31
|
+
is(`${name}: оформление чистое (${r.problems.map((p) => `[${p.rule}] ${p.id} ${p.why}`).join('; ') || 'ни одной находки'})`, r.ok);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// По одному огреху на правило, на отчёте cimes.ru.
|
|
35
|
+
const fires = async (rule, label, spoil) => {
|
|
36
|
+
const { a, lang } = await build('cimes');
|
|
37
|
+
spoil(a);
|
|
38
|
+
const r = presentationCheck(a, { lang });
|
|
39
|
+
is(`правило ${rule}: ${label}`, r.problems.some((p) => p.rule === rule));
|
|
40
|
+
};
|
|
41
|
+
await fires(1, 'пустой раздел «Неделя 1»', (a) => { a.roadmap[0].items = []; });
|
|
42
|
+
await fires(2, '«Сейчас: 1: t.me»', (a) => { a.summary.priorities[0] = 'Заявить профили. Сейчас: 1: t.me'; });
|
|
43
|
+
await fires(2, 'undefined в тексте', (a) => { a.summary.lead += ' балл undefined'; });
|
|
44
|
+
await fires(3, 'фраза оборвана на «на»', (a) => { a.summary.verdict = 'Та же дата видна на самой странице на'; });
|
|
45
|
+
await fires(3, 'фраза оборвана на «на 1»', (a) => { const c = a.checks.find((x) => x.id === 'dates'); c.value = '1 из 20 страниц выборки указывают дату в разметке; та же дата видна на самой странице на 1'; });
|
|
46
|
+
await fires(4, '«Норма» при 1 из 20', (a) => { const c = a.checks.find((x) => x.id === 'dates'); c.status = 'ok'; c.value = '1 из 20 страниц выборки указывают дату в разметке'; });
|
|
47
|
+
await fires(5, '«Тонкие страницы» в сильных сторонах', (a) => { a.content.strengths = ['Тонкие страницы: медиана 1618 слов на страницу', ...(a.content.strengths || [])]; });
|
|
48
|
+
await fires(6, '«не измеряли» при найденном движке', (a) => { a.checks.push({ id: 'cms', group: 'overview', label: 'Движок сайта', status: 'na', value: '1C-Bitrix (определён по коду страницы)' }); a.overview.rows[0][1] = 'не измеряли'; });
|
|
49
|
+
await fires(7, 'в отчёте 5 страниц, в задаче 2', (a) => { const c = a.checks.find((x) => x.id === 'sections'); c.status = 'warn'; c.value = 'меньше трёх подзаголовков H2 на 5 из 20 страниц выборки'; a.taskOverrides = { sections: { task: 'Разбей на разделы страницы /a/, /b/.' } }; });
|
|
50
|
+
await fires(8, 'длинное тире в нашей фразе', (a) => { a.summary.verdict = 'Сайт в порядке — почти'; });
|
|
51
|
+
|
|
52
|
+
// Команда для разработчика с /dev/null не огрех.
|
|
53
|
+
{
|
|
54
|
+
const { a, lang } = await build('cimes');
|
|
55
|
+
a.taskOverrides = { ...(a.taskOverrides || {}), sections: { verify: 'Три замера (curl -o /dev/null -w "%{time_starttransfer}" адрес главной) показывают не больше 0,8 секунды.' } };
|
|
56
|
+
is('команда curl с /dev/null не огрех', !presentationCheck(a, { lang }).problems.some((p) => p.rule === 2));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Тире в цитате с сайта клиента не огрех.
|
|
60
|
+
{
|
|
61
|
+
const { a, lang } = await build('cimes');
|
|
62
|
+
a.client.subject = 'По описанию на главной: Студия — сайты под ключ.';
|
|
63
|
+
is('тире в цитате с сайта клиента не огрех', !presentationCheck(a, { lang }).problems.some((p) => p.rule === 8));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
67
|
+
console.log('\nпроверка оформления: все правила работают, эталоны чистые');
|
package/src/test-rendered.mjs
CHANGED
|
@@ -121,5 +121,16 @@ ok('английская версия без кириллицы', !/[А-Яа-я
|
|
|
121
121
|
rmSync(dir, { recursive: true, force: true });
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// cimes.ru 14.10.2026: меню, достроенное скриптом вне <nav>, и баннер cookie не «скрытый текст».
|
|
125
|
+
{
|
|
126
|
+
const body = '<main><p>' + 'Создание сайтов под ключ для бизнеса '.repeat(40) + '</p></main>';
|
|
127
|
+
const raw = `<html><body><header><div class="logo">Cimes</div></header>${body}</body></html>`;
|
|
128
|
+
const ren = raw.replace('</header>', '<div class="header-menu dropdown">' + 'Услуги Готовые решения Аспро Максимум Подробнее Демо '.repeat(30) + '</div></header>')
|
|
129
|
+
.replace('</main>', '</main><div class="cookie-settings"><p>' + 'Управление cookie файлами принять все '.repeat(20) + '</p></div>');
|
|
130
|
+
is('меню и баннер cookie не скрытый текст', compareReadings(raw, ren).hiddenShare, 0);
|
|
131
|
+
const spa = `<html><body><div id="root"></div></body></html>`;
|
|
132
|
+
ok('настоящая страница на скриптах по-прежнему видна', compareReadings(spa, spa.replace('<div id="root"></div>', body)).verdict === 'bad');
|
|
133
|
+
}
|
|
134
|
+
|
|
124
135
|
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
125
136
|
console.log('\nвсе тесты слепоты к скриптам прошли');
|
|
@@ -131,7 +131,7 @@ try {
|
|
|
131
131
|
is('4. FAQPage найдена', check('faq-schema').status, 'ok');
|
|
132
132
|
|
|
133
133
|
// 6. Профили из sameAs со слешами \/
|
|
134
|
-
is('6. профили прочитаны', check('trust-profiles').value, '3:
|
|
134
|
+
is('6. профили прочитаны', check('trust-profiles').value, '3 profiles: Instagram, YouTube, Facebook');
|
|
135
135
|
|
|
136
136
|
// 7, 10. llms.txt
|
|
137
137
|
ok(`7. адрес с обратной кавычкой не мёртвый (${check('llms-health').value})`, check('llms-health').status === 'ok');
|
package/src/verify.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { __readSitemap as readSitemap, policyKinds, computeScores, computeOveral
|
|
|
13
13
|
import { localiseChecks } from './i18n.mjs';
|
|
14
14
|
import { detectNiche } from './niche.mjs';
|
|
15
15
|
import { MECHANICAL_IDS } from './narrative.mjs';
|
|
16
|
+
import { profileLine } from './i18n.mjs';
|
|
16
17
|
import { request as httpsRequest } from 'node:https';
|
|
17
18
|
import { request as httpRequest } from 'node:http';
|
|
18
19
|
|
|
@@ -87,15 +88,19 @@ export function rebuildCritical(audit) {
|
|
|
87
88
|
const ru = audit.meta?.lang === 'ru';
|
|
88
89
|
const shown = ru ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
|
|
89
90
|
const bad = shown.filter((c) => c.status === 'bad');
|
|
90
|
-
|
|
91
|
+
// После черновика первым идёт ровно список приоритетов (priorityIds): пересчёт по ходу проверки не должен его менять (cimes.ru, 14.10.2026).
|
|
92
|
+
const pri = (audit.priorityIds || []).map((id) => shown.find((c) => c.id === id)).filter((c) => c && (c.status === 'bad' || c.status === 'warn'));
|
|
93
|
+
audit.critical = (pri.length ? pri : bad).map((c) => ({ title: c.label, text: joinValueComment(c.value, c.comment), level: c.status === 'warn' ? 'warn' : 'bad' }));
|
|
91
94
|
const fix = ru ? 'Исправить' : 'Fix';
|
|
92
95
|
const week1 = (audit.roadmap || [])[0]; const week2 = (audit.roadmap || [])[1];
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
96
|
+
// После черновика план уже собран из приоритетов и переведён в действия: пересчёт по ходу проверки его не трогает.
|
|
97
|
+
if (week1 && Array.isArray(week1.items) && !pri.length) {
|
|
98
|
+
// Первая неделя = раздел «Что чинить первым» (все красные строки, технические впереди) плюс до двух быстрых
|
|
99
|
+
// технических частичных. Раньше план уносил красные строки с фактами владельца на недели 2-4, а раздел «Срочно»
|
|
100
|
+
// ставил их первыми: отчёт спорил сам с собой (cimes.ru, 14.10.2026).
|
|
101
|
+
const quick = pri.length ? [...pri] : [...bad.filter((c) => MECHANICAL_IDS.has(c.id)), ...bad.filter((c) => !MECHANICAL_IDS.has(c.id))];
|
|
102
|
+
if (!pri.length) quick.push(...shown.filter((c) => c.status === 'warn' && MECHANICAL_IDS.has(c.id) && c.id !== 'trust-policies').slice(0, Math.max(0, 3 - quick.length) || (quick.length ? 0 : 2)));
|
|
103
|
+
const slow = [];
|
|
99
104
|
const other = week1.items.filter((i) => !String(i).startsWith(`${fix}:`) && !String(i).includes('{{'));
|
|
100
105
|
week1.items = [...quick.map((c) => `${fix}: ${c.label}`), ...other];
|
|
101
106
|
if (week2 && Array.isArray(week2.items)) {
|
|
@@ -103,6 +108,7 @@ export function rebuildCritical(audit) {
|
|
|
103
108
|
week2.items = [...slow.map((c) => `${fix}: ${c.label}`), ...keep, ru ? 'Остальные задачи по порядку из файла задач' : 'The rest of the tasks, in order, from the tasks file'];
|
|
104
109
|
week2.items = [...new Set(week2.items)];
|
|
105
110
|
}
|
|
111
|
+
if (!week1.items.length) audit.roadmap = audit.roadmap.filter((st) => st !== week1);
|
|
106
112
|
}
|
|
107
113
|
return audit;
|
|
108
114
|
}
|
|
@@ -184,7 +190,7 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
184
190
|
const ownHref = (h, base) => { try { return new URL(h, base).host.replace(/^www\./, '') === new URL(base).host.replace(/^www\./, ''); } catch { return false; } };
|
|
185
191
|
const hit = pages.find((p) => hrefs(p.html).some((h) => ownHref(h, p.url) && ABOUT_HREF.test(h)) || ABOUT_HREF.test(new URL(p.url).pathname) || /"@type"\s*:\s*"AboutPage"/.test(p.html) || ABOUT_TITLE.test(titleOf(p.html)) || h1sOf(p.html).some((h) => ABOUT_TITLE.test(h)));
|
|
186
192
|
if (hit) change('trust-about', { status: 'ok', value: ru ? `есть: найдено на ${path(hit.url)}` : `present: found via ${path(hit.url)}` }, { url: hit.url });
|
|
187
|
-
else change('trust-about', { value: ru ? `страницы о компании нет ни на одной из ${pages.length} прочитанных страниц` : `no about or team page on any of the ${pages.length} pages read` }, { pagesRead: pages.length });
|
|
193
|
+
else change('trust-about', { status: 'bad', value: ru ? `страницы о компании нет ни на одной из ${pages.length} прочитанных страниц` : `no about or team page on any of the ${pages.length} pages read` }, { pagesRead: pages.length });
|
|
188
194
|
}
|
|
189
195
|
|
|
190
196
|
// 3. Мессенджеры.
|
|
@@ -204,7 +210,8 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
204
210
|
|
|
205
211
|
// 4. Профили: sameAs с разэкранированными слешами, плюс ссылки на соцсети в самих страницах.
|
|
206
212
|
const prof = byId('trust-profiles');
|
|
207
|
-
|
|
213
|
+
// В разметке только мессенджеры: это уже точный ответ сбора, ссылки t.me на страницах его не меняют (cimes.ru, 15.10.2026).
|
|
214
|
+
if (prof && prof.status !== 'ok' && !prof.messengersOnly && !prof.listings) {
|
|
208
215
|
const own = audit.meta.host.replace(/^www\./, '');
|
|
209
216
|
const inSchema = new Set(); const inLinks = new Set();
|
|
210
217
|
for (const p of pages) {
|
|
@@ -212,7 +219,7 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
212
219
|
for (const m of ld.matchAll(/"sameAs"\s*:\s*(\[[^\]]*\]|"[^"]+")/g)) for (const u of m[1].match(/https?:\/\/[^"\s,\]]+/g) || []) { try { const h = new URL(u).host.replace(/^www\./, ''); if (h !== own && !/^(wa\.me|api\.whatsapp\.com|whatsapp\.com|chat\.whatsapp\.com|viber\.com|invite\.viber\.com)$/i.test(h)) inSchema.add(h); } catch { /* мусор */ } }
|
|
213
220
|
for (const m of String(p.html).matchAll(SOCIAL)) inLinks.add(m[1].toLowerCase());
|
|
214
221
|
}
|
|
215
|
-
if (inSchema.size) change('trust-profiles', { status: inSchema.size >= 2 ? 'ok' : 'warn', value:
|
|
222
|
+
if (inSchema.size) change('trust-profiles', { status: inSchema.size >= 2 ? 'ok' : 'warn', value: profileLine([...inSchema].slice(0, 5), ru ? 'ru' : 'en', inSchema.size) }, { inSchema: [...inSchema] });
|
|
216
223
|
else if (inLinks.size) change('trust-profiles', { status: 'warn', value: ru ? `в разметке не заявлены, но ссылки на сайте есть: ${[...inLinks].join(', ')}` : `not declared in the markup, though the pages link to: ${[...inLinks].join(', ')}` }, { inLinks: [...inLinks] });
|
|
217
224
|
else change('trust-profiles', { value: ru ? `ни в разметке, ни ссылками ни на одной из ${pages.length} прочитанных страниц профилей нет` : `no profile in the markup or as a link on any of the ${pages.length} pages read` }, { pagesRead: pages.length });
|
|
218
225
|
}
|
|
@@ -242,7 +249,7 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
242
249
|
const fmt = (x) => (ru ? `${(x / 1000).toFixed(1).replace('.', ',')} с` : `${(x / 1000).toFixed(1)} s`);
|
|
243
250
|
// Пороги web.dev: до 0,8 с хорошо, до 1,8 с терпимо, дальше плохо.
|
|
244
251
|
const status = mid <= 800 ? 'ok' : mid <= 1800 ? 'warn' : 'bad';
|
|
245
|
-
change('ttfb', { status, value: ru ?
|
|
252
|
+
change('ttfb', { status, value: ru ? (fmt(lo) === fmt(hi) ? `${fmt(mid)} во всех ${runs.length} замерах с сервера в Германии` : `медиана ${fmt(mid)} по ${runs.length} замерам (от ${fmt(lo)} до ${fmt(hi)}), замер с сервера в Германии`) : (fmt(lo) === fmt(hi) ? `${fmt(mid)} in all ${runs.length} requests from a server in Germany` : `median ${fmt(mid)} over ${runs.length} requests (${fmt(lo)} to ${fmt(hi)}), measured from a server in Germany`), comment: ru ? 'хорошим Google считает ответ до 0,8 секунды; посетители из другой страны могут видеть другое время' : 'Google calls 0.8 seconds or less good; visitors elsewhere may see a different time' }, { runs });
|
|
246
253
|
}
|
|
247
254
|
}
|
|
248
255
|
}
|
|
@@ -268,7 +275,7 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
268
275
|
audit.niche = audit.niche || detectNiche(audit);
|
|
269
276
|
if (audit.niche.niche !== 'b2b') for (const id of ['agent-card', 'api-catalog', 'agent-markdown']) {
|
|
270
277
|
const c = byId(id);
|
|
271
|
-
if (c && c.status === 'warn') change(id, { status: 'na', comment: ru ? 'необязательно для этого сайта: нужно, только если у компании есть свой ИИ-сервис или API' : 'optional for this site: needed only when the business runs its own AI service or API' }, { niche: audit.niche.niche });
|
|
278
|
+
if (c && c.status === 'warn') change(id, { status: 'na', comment: id === 'agent-markdown' ? (ru ? 'необязательно для этого сайта: текстовые копии страниц нужны тем, кого агенты читают как документацию' : 'optional for this site: text copies of pages matter to sites that agents read as documentation') : (ru ? 'необязательно для этого сайта: нужно, только если у компании есть свой ИИ-сервис или API' : 'optional for this site: needed only when the business runs its own AI service or API') }, { niche: audit.niche.niche });
|
|
272
279
|
}
|
|
273
280
|
// Роботы ИИ по-настоящему: robots.txt может их пускать, а сервер обрывать соединение (14.10.2026, medeq-mc.ru).
|
|
274
281
|
const robotsUrl = `${new URL(audit.meta.site).origin}/robots.txt`;
|