@operstack/audit 0.36.2 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/bin/audit.mjs +2 -2
- package/package.json +5 -3
- package/src/client-eye.mjs +389 -0
- package/src/collect.mjs +171 -85
- package/src/fix.mjs +124 -5
- package/src/foundation.mjs +2 -1
- package/src/i18n.mjs +50 -25
- package/src/index.mjs +2 -1
- package/src/narrative.mjs +56 -25
- package/src/niche.mjs +13 -12
- package/src/pagekind.mjs +18 -1
- package/src/presentation.mjs +3 -2
- package/src/prompts.mjs +163 -42
- package/src/render.mjs +61 -30
- package/src/rendered.mjs +6 -2
- package/src/tasks-pdf.mjs +10 -5
- package/src/verify.mjs +26 -13
- package/src/visibility.mjs +137 -45
- package/src/vy.mjs +13 -0
- package/src/test-agency-ids.mjs +0 -48
- package/src/test-agency.mjs +0 -158
- package/src/test-agent-surface.mjs +0 -46
- package/src/test-answer-first.mjs +0 -27
- package/src/test-blocked.mjs +0 -100
- package/src/test-crawl-links.mjs +0 -87
- package/src/test-demand.mjs +0 -243
- package/src/test-etalon.mjs +0 -42
- package/src/test-fix.mjs +0 -115
- package/src/test-flaky-files.mjs +0 -81
- package/src/test-foundation.mjs +0 -126
- package/src/test-handover.mjs +0 -148
- package/src/test-i18n.mjs +0 -68
- package/src/test-narrative.mjs +0 -117
- package/src/test-overall.mjs +0 -236
- package/src/test-planted.mjs +0 -114
- package/src/test-presentation.mjs +0 -67
- package/src/test-prompts.mjs +0 -142
- package/src/test-prospect-count.mjs +0 -12
- package/src/test-redirect-host.mjs +0 -63
- package/src/test-rendered.mjs +0 -136
- package/src/test-report-wording.mjs +0 -35
- package/src/test-ru-report.mjs +0 -95
- package/src/test-ru-sources.mjs +0 -41
- package/src/test-sample-fairness.mjs +0 -50
- package/src/test-sample.mjs +0 -91
- package/src/test-scores.mjs +0 -75
- package/src/test-shopify-store.mjs +0 -159
- package/src/test-sitemap.mjs +0 -91
- package/src/test-unmeasured.mjs +0 -173
package/src/render.mjs
CHANGED
|
@@ -14,7 +14,13 @@ import { softenHex } from './agency.mjs';
|
|
|
14
14
|
import { renderDemandHtml, demandSourceRow } from './demand.mjs';
|
|
15
15
|
import { buildLlmsTxt, buildOrganisationSchema, fixTitlesAndDescriptions } from './handover.mjs';
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Кириллические адреса в тексте показываем буквами: «%D0%B2%D0%B8…» исполнитель не читает, а длинная строка уходила за край листа (15.10.2026).
|
|
18
|
+
// Раскрываем только цепочки с не-ASCII байтами, служебные «%20» и «%2F» не трогаем.
|
|
19
|
+
const unpct = (s) => String(s ?? '').replace(/(?:%[0-9A-Fa-f]{2})*%[89A-Fa-f][0-9A-Fa-f](?:%[0-9A-Fa-f]{2})*/g, (m) => { try { return decodeURIComponent(m); } catch { return m; } });
|
|
20
|
+
// Значение и пояснение в таблицах законченными предложениями: без точки пояснение «…искать» читалось как оборванное (16.10.2026).
|
|
21
|
+
const fin = (t) => { const x = String(t ?? '').trim(); return !x || /[.!?…]$/.test(x) ? x : `${x}.`; };
|
|
22
|
+
const cap = (t) => { const x = String(t ?? ''); return x ? x[0].toUpperCase() + x.slice(1) : x; };
|
|
23
|
+
const esc = (s) => unpct(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
18
24
|
const isPlaceholder = (s) => typeof s === 'string' && /\{\{[^}]*\}\}/.test(s);
|
|
19
25
|
const t = (s) => (isPlaceholder(s) ? `<mark class="todo">${esc(s)}</mark>` : esc(s));
|
|
20
26
|
|
|
@@ -56,7 +62,7 @@ const LABELS = {
|
|
|
56
62
|
appendixEyebrow: 'Appendix', appendixPages: 'Every page we read', fullListInAppendix: 'The first pages are shown here; every page we read is listed in the appendix at the end.',
|
|
57
63
|
readNote: (read, wanted) => (read === wanted ? `All ${wanted} pages answered and were read.` : `${read} of ${wanted} pages answered and were read; the rest did not answer or the site stopped us.`),
|
|
58
64
|
secOverview: 'Site overview', overviewEyebrow: '02 · Overview', whatSiteIs: 'What the site is', parameter: 'Parameter', value: 'Value', pagesSampled: 'Pages sampled', colUrl: 'URL', colTitle: 'Title', colWords: 'Words',
|
|
59
|
-
secCritical: 'Critical issues', criticalEyebrow: '03 · P0', criticalTitle: 'Critical issues, fix first', whatItCosts: 'What it costs', theFix: 'Fix', noBody: 'This issue has no description in the audit file.', noCritical: 'No critical defects were found in the public signals.',
|
|
65
|
+
secCritical: 'Critical issues', criticalEyebrow: '03 · P0', criticalTitle: 'Critical issues, fix first', secFirst: 'Fix first', firstEyebrow: '03 · First', firstTitle: 'What to fix first', whatItCosts: 'What it costs', theFix: 'Fix', noBody: 'This issue has no description in the audit file.', noCritical: 'No critical defects were found in the public signals.',
|
|
60
66
|
secTechnical: 'Technical SEO', technicalEyebrow: '04 · Technical', technicalTitle: 'Technical and on-page checklist', colCheck: 'Check', colStatus: 'Status', colFinding: 'Finding',
|
|
61
67
|
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
68
|
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',
|
|
@@ -65,7 +71,7 @@ const LABELS = {
|
|
|
65
71
|
limitations: 'Limitations',
|
|
66
72
|
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
73
|
runningHead: 'SEO, AEO and GEO audit',
|
|
68
|
-
statusOk: '✓ OK', statusWarn: '△ Partial', statusBad: '✗ Problem', statusNa: '· Note',
|
|
74
|
+
statusOk: '✓ OK', statusWarn: '△ Partial', statusBad: '✗ Problem', statusNa: '· Note', statusAbsent: '△ Missing, half credit',
|
|
69
75
|
areas: null,
|
|
70
76
|
basisNote: (b) => `${b.ok} of ${b.counted} checks pass${b.warn ? `, ${b.warn} ${b.warn === 1 ? 'needs' : 'need'} attention` : ''}${b.bad ? `, ${b.bad} ${b.bad === 1 ? 'fails' : 'fail'}` : ''}`,
|
|
71
77
|
notMeasuredReason: { 'Off-page and trust': 'no link or mention data was collected', 'Conversion and UX': 'no conversion signals were collected' },
|
|
@@ -90,7 +96,7 @@ const LABELS = {
|
|
|
90
96
|
const areas = Array.isArray(o.areas) ? o.areas : [];
|
|
91
97
|
const measured = areas.filter((a) => a.measured !== false && a.score !== null);
|
|
92
98
|
const sumUp = areas.length && measured.length < areas.length
|
|
93
|
-
? `Из пяти областей ниже измерены ${measured.length}, они дают ${measured.reduce((t, a) => t + a.score, 0)} из ${measured.reduce((t, a) => t + a.max, 0)}, и балл
|
|
99
|
+
? `Из пяти областей ниже измерены ${measured.length}, они дают ${measured.reduce((t, a) => t + a.score, 0)} из ${measured.reduce((t, a) => t + a.max, 0)}, и балл показывает ту же долю, перенесённую на сто. Область с пометкой «не измерялось» не зачтена ни в плюс, ни в минус. `
|
|
94
100
|
: 'Пять областей ниже дают в сумме ровно этот балл, его можно сложить руками. ';
|
|
95
101
|
return `${where}${read}${sumUp}Повторная проверка может отличаться на пункт-другой: проверка, которая успела ответить в один прогон, может не успеть в следующий, а балл никогда не засчитывает сайту в минус то, что не удалось прочитать.`;
|
|
96
102
|
},
|
|
@@ -104,16 +110,16 @@ const LABELS = {
|
|
|
104
110
|
appendixEyebrow: 'Приложение', appendixPages: 'Все прочитанные страницы', fullListInAppendix: 'Здесь первые строки, весь список прочитанных страниц лежит приложением в конце отчёта.',
|
|
105
111
|
readNote: (read, wanted) => (read === wanted ? `Ответили и прочитаны все ${wanted} страниц.` : `Из ${wanted} страниц ответили и прочитаны ${read}; остальные не ответили или сайт нас остановил.`),
|
|
106
112
|
secOverview: 'О сайте', overviewEyebrow: '02 · Обзор', whatSiteIs: 'Что это за сайт', parameter: 'Параметр', value: 'Значение', pagesSampled: 'Проверенные страницы', colUrl: 'Адрес', colTitle: 'Заголовок', colWords: 'Слов',
|
|
107
|
-
secCritical: 'Критичное', criticalEyebrow: '03 · Срочно', criticalTitle: 'Что чинить первым', whatItCosts: 'Чем это грозит', theFix: 'Как чинится', noBody: 'У этой проблемы нет описания в файле аудита.', noCritical: 'Критичных дефектов в публичных сигналах не найдено.',
|
|
113
|
+
secCritical: 'Критичное', criticalEyebrow: '03 · Срочно', criticalTitle: 'Что чинить первым', secFirst: 'Первым делом', firstEyebrow: '03 · Первым делом', firstTitle: 'Что чинить первым', whatItCosts: 'Чем это грозит', theFix: 'Как чинится', noBody: 'У этой проблемы нет описания в файле аудита.', noCritical: 'Критичных дефектов в публичных сигналах не найдено.',
|
|
108
114
|
secTechnical: 'Техническое SEO', technicalEyebrow: '04 · Техника', technicalTitle: 'Техническая проверка и страницы', colCheck: 'Проверка', colStatus: 'Статус', colFinding: 'Что нашли',
|
|
109
115
|
secContent: 'Контент и страницы', contentEyebrow: '05 · Контент', contentTitle: 'Контент и внутренняя оптимизация', strengths: 'Сильные стороны', weaknesses: 'Слабые места', gaps: 'Структурные дыры: страницы, которых пока нет',
|
|
110
|
-
secAeo: 'AEO и GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Ответные системы: AEO', aeoLead: 'Здесь видно, может ли ответ с вашего сайта попасть в блок «Люди также спрашивают» и в ответы
|
|
116
|
+
secAeo: 'AEO и GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Ответные системы: AEO', aeoLead: 'Здесь видно, может ли ответ с вашего сайта попасть в блок «Люди также спрашивают» и в ответы ChatGPT, Perplexity и Алисы.', aeoWorks: 'Что уже работает', aeoBlocks: 'Что мешает', recommendation: 'Рекомендация:', geoTitle: 'Генеративные системы: GEO', colSignal: 'Сигнал', colDetail: 'Подробности',
|
|
111
117
|
secOffpage: 'Ссылки и конверсия', offpageEyebrow: '07 · Ссылки', offpageTitle: 'Ссылки и доверие', conversionEyebrow: '08 · Конверсия', conversionTitle: 'Конверсия и удобство', colElement: 'Элемент', limitationsEyebrow: '10 · Ограничения', sourcesTitle: 'Откуда взяты данные', colSource: 'Источник', colAccess: 'Доступ', colWhatRead: 'Что прочитали',
|
|
112
118
|
sourcesFoot: 'Каждая оценка в этом отчёте посчитана из источников с пометкой «бесплатно». Ничего платного ни в одну оценку не входит, поэтому вы можете повторить этот аудит сами и получить те же или очень близкие цифры.',
|
|
113
119
|
limitations: 'Ограничения',
|
|
114
120
|
secRoadmap: 'План работ и ограничения', roadmapEyebrow: '09 · План', demandEyebrow: '11 · Спрос', demandTitle: 'Карта спроса', secDemand: 'Спрос', roadmapTitle: 'Что делать и в каком порядке', keyMessage: 'Главное сообщение:',
|
|
115
121
|
runningHead: 'Аудит SEO, AEO и GEO',
|
|
116
|
-
statusOk: '✓ Норма', statusWarn: '△ Частично', statusBad: '✗ Проблема', statusNa: '· Заметка',
|
|
122
|
+
statusOk: '✓ Норма', statusWarn: '△ Частично', statusBad: '✗ Проблема', statusNa: '· Заметка', statusAbsent: '△ Нет, полбалла',
|
|
117
123
|
areas: AREAS_RU,
|
|
118
124
|
basisNote: (b) => localiseBasisNote(`${b.ok} of ${b.counted} checks pass${b.warn ? `, ${b.warn} ${b.warn === 1 ? 'needs' : 'need'} attention` : ''}${b.bad ? `, ${b.bad} ${b.bad === 1 ? 'fails' : 'fail'}` : ''}`),
|
|
119
125
|
notMeasuredReason: { 'Off-page and trust': 'данные по ссылкам и упоминаниям не собирались', 'Conversion and UX': 'сигналы конверсии не собирались' },
|
|
@@ -183,7 +189,7 @@ function css() {
|
|
|
183
189
|
@page { size: A4; margin: 0; }
|
|
184
190
|
:root { --ink:#14181c; --ink-2:#3b474d; --muted:#5c6b6f; --rule:#d8dedb; --paper:#fff; --panel:#f3f5f6; --accent:#0b7a75; --accent-soft:#dcefed; --gold:#8a6b38; --gold-soft:#f5efe3; --danger:#b3261e; --warn:#9a6700; --ok:#1f7a3d; --pad:16mm; }
|
|
185
191
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
186
|
-
body { font-family: "IBM Plex Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; color: var(--ink); background: #e9ecec; font-size: 10.2pt; line-height: 1.5; }
|
|
192
|
+
body { font-family: "IBM Plex Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; color: var(--ink); background: #e9ecec; font-size: 10.2pt; line-height: 1.5; overflow-wrap: break-word; }
|
|
187
193
|
.page { width: 210mm; min-height: 297mm; margin: 0 auto 8mm; background: var(--paper); padding: var(--pad) var(--pad) 20mm; position: relative; page-break-after: always; }
|
|
188
194
|
.page:last-child { page-break-after: auto; }
|
|
189
195
|
h1, h2, h3 { font-family: "Fraunces", "Iowan Old Style", Georgia, serif; font-weight: 600; color: var(--ink); line-height: 1.15; }
|
|
@@ -337,7 +343,13 @@ const header = (audit, section, L) => `<div class="page-header"><strong>${esc(au
|
|
|
337
343
|
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
344
|
const list = (items) => `<ul>${(items || []).filter((i) => String(i ?? '').trim()).map((i) => `<li>${t(i)}</li>`).join('')}</ul>`;
|
|
339
345
|
const olist = (items) => `<ol>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ol>`;
|
|
340
|
-
|
|
346
|
+
/*
|
|
347
|
+
* Частичная проверка, где элемента просто нет («llms.txt: нет», «хлебные крошки не найдены»), подписывается «Нет», а не
|
|
348
|
+
* «Частично»: проверяющий читал «частично» при полном отсутствии как противоречие (прогон на 20 сайтах, 15.10.2026).
|
|
349
|
+
* Статус и баллы не меняются, только подпись.
|
|
350
|
+
*/
|
|
351
|
+
const ABSENT = /^(?:нет|не найден\p{L}*|not present|none|not found|no\b)|\b(?:нет ни на одной|не найден\p{L}* в выборке|none on|not found in)/iu;
|
|
352
|
+
const statusCell = (s, L, value = '') => { const absent = s === 'warn' && ABSENT.test(String(value || '').trim()); return `<td class="${STATUS_CLASS[s] || STATUS_CLASS.na}">${absent ? (L.statusAbsent || L[STATUS_KEYS[s]]) : L[STATUS_KEYS[s] || 'statusNa']}</td>`; };
|
|
341
353
|
|
|
342
354
|
/*
|
|
343
355
|
* Название документа по тому, что купили (08.10.2026). Покупатель «Всех правок по сайту» за 800 ₽
|
|
@@ -399,6 +411,12 @@ export function toHtml(audit, brand = null) {
|
|
|
399
411
|
const pill = (label, score) => `<span class="pill pill-${tone(score)}"><span class="pill-k">${esc(label)}</span> <b>${grade100(score)}</b> <span class="pill-n">${score}/100</span></span>`;
|
|
400
412
|
const seo = a.overall && a.overall.seo && typeof a.overall.seo.score === 'number' ? a.overall.seo : null;
|
|
401
413
|
const engines = Array.isArray(a.overall && a.overall.engines) ? a.overall.engines : [];
|
|
414
|
+
// Плашка «Основы SEO 87» без объяснения: число нигде больше не встречалось (oper-stack.com, прогон на своих сайтах
|
|
415
|
+
// 15.10.2026). Под плашками одна строка: из чего число и что оно отдельно от балла ИИ-видимости.
|
|
416
|
+
const seoParts = seo && Array.isArray(seo.checks) ? seo.checks.filter((c) => c && c.total) : [];
|
|
417
|
+
const seoNote = seoParts.length ? `<p class="overall-note">${esc(a.meta?.lang === 'ru'
|
|
418
|
+
? `${L.seoBasics} ${seo.score}: средняя доля пройденных базовых проверок на ${seoParts[0].total} страницах (заголовок, описание, H1, canonical, карточка для соцсетей, открытость для индексации). Это отдельное число, в балл ИИ-видимости оно не входит.`
|
|
419
|
+
: `${L.seoBasics} ${seo.score}: the average share of basic checks passed on ${seoParts[0].total} pages (title, description, H1, canonical, social card, open to indexing). It is a separate number, not part of the AI visibility score.`)}</p>` : '';
|
|
402
420
|
|
|
403
421
|
const overallBlock = !head ? '' : `<div class="overall">
|
|
404
422
|
${ring(head.score)}
|
|
@@ -406,6 +424,7 @@ export function toHtml(audit, brand = null) {
|
|
|
406
424
|
<p class="pills">${pill(L.aiVisibility || 'AI visibility', head.score)}${seo ? pill(L.seoBasics || 'SEO basics', seo.score) : ''}</p>
|
|
407
425
|
<div class="overall-grade">${esc(head.grade)}</div>
|
|
408
426
|
<p class="overall-note">${esc(head.note)}</p>
|
|
427
|
+
${seoNote}
|
|
409
428
|
</div>
|
|
410
429
|
</div>`
|
|
411
430
|
+ (engines.length ? `<h3 class="engines-head">${esc(L.enginesHead || 'Readiness by engine')}</h3><div class="engines">${engines.map((e) => {
|
|
@@ -440,10 +459,10 @@ export function toHtml(audit, brand = null) {
|
|
|
440
459
|
const subjectText = t(a.client.subject);
|
|
441
460
|
/* Обрыв на полуслове в первой строке отчёта за 149 выглядит как недоделка, поэтому режем по точке. */
|
|
442
461
|
const subjectShort = (() => {
|
|
443
|
-
if (subjectText.length <=
|
|
444
|
-
const cut = subjectText.slice(0,
|
|
462
|
+
if (subjectText.length <= 320) return subjectText;
|
|
463
|
+
const cut = subjectText.slice(0, 320);
|
|
445
464
|
const stop = cut.lastIndexOf('. ');
|
|
446
|
-
return stop > 60 ? cut.slice(0, stop + 1) : `${cut.
|
|
465
|
+
return stop > 60 ? cut.slice(0, stop + 1) : `${cut.replace(/[\s,;:]+\S*$/, '')}\u2026`;
|
|
447
466
|
})();
|
|
448
467
|
const masthead = `<div class="masthead">${logo}<p class="mast-eyebrow">${L.eyebrowAudit}</p><h1 class="mast-title">${L.coverTitle.replace(/<br>/g, ' ')}</h1><p class="mast-sub">${subjectShort}</p><dl class="mast-meta"><div><dt>${L.auditSubject}</dt><dd>${t(a.client.name)}</dd></div><div><dt>${L.reportDate}</dt><dd>${esc(a.client.reportDate)}</dd></div><div><dt>${L.auditType}</dt><dd>${esc(a.meta.auditType)}</dd></div><div><dt>${L.preparedBy}</dt><dd>${esc(preparedBy)}</dd></div></dl></div>`;
|
|
449
468
|
/*
|
|
@@ -482,13 +501,13 @@ export function toHtml(audit, brand = null) {
|
|
|
482
501
|
const appendixRow = (x) => {
|
|
483
502
|
const title = String(x.title || '');
|
|
484
503
|
const short = title.length > 52 ? `${title.slice(0, 50).trimEnd()}\u2026` : title;
|
|
485
|
-
return `<tr><td><code>${esc(new URL(x.url).pathname)}</code></td><td>${esc(short)}</td><td>${x.words}</td><td>${x.h2Count ?? '\u00b7'}</td><td>${x.tables ?? '\u00b7'}</td><td>${x.linkCount ?? '\u00b7'}</td><td>${x.images ? `${x.images - x.imagesNoAlt}/${x.images}` : '\u00b7'}</td></tr>`;
|
|
504
|
+
return `<tr><td><code>${esc(new URL(x.url).pathname)}</code></td><td>${esc(short)}</td><td>${x.words}</td><td>${x.h2Count ?? '\u00b7'}</td><td>${x.tables ?? '\u00b7'}</td><td>${x.linkCount ?? '\u00b7'}</td><td>${x.images != null ? `${x.images - x.imagesNoAlt}/${x.images}` : '\u00b7'}</td></tr>`;
|
|
486
505
|
};
|
|
487
506
|
|
|
488
507
|
const asAppendix = allSampleRows.length > APPENDIX_FROM;
|
|
489
508
|
const sampleRows = asAppendix ? allSampleRows.slice(0, 7) : allSampleRows;
|
|
490
509
|
const pagesHead = `<tr><th>${L.colUrl}</th><th>${L.colTitle}</th><th>${L.colWords}</th><th>H2</th><th>${L.colTables || 'Tables'}</th><th>${L.colLinks || 'Links'}</th><th>Alt</th></tr>`;
|
|
491
|
-
const pageRow = (x) => `<tr><td><code>${esc(new URL(x.url).pathname)}</code></td><td>${esc(x.title)}</td><td>${x.words}</td><td>${x.h2Count ?? '\u00b7'}</td><td>${x.tables ?? '\u00b7'}</td><td>${x.linkCount ?? '\u00b7'}</td><td>${x.images ? `${x.images - x.imagesNoAlt}/${x.images}` : '\u00b7'}</td></tr>`;
|
|
510
|
+
const pageRow = (x) => `<tr><td><code>${esc(new URL(x.url).pathname)}</code></td><td>${esc(x.title)}</td><td>${x.words}</td><td>${x.h2Count ?? '\u00b7'}</td><td>${x.tables ?? '\u00b7'}</td><td>${x.linkCount ?? '\u00b7'}</td><td>${x.images != null ? `${x.images - x.imagesNoAlt}/${x.images}` : '\u00b7'}</td></tr>`;
|
|
492
511
|
const FIRST_PAGE_ROWS = 7;
|
|
493
512
|
const NEXT_PAGE_ROWS = 16;
|
|
494
513
|
const firstRows = sampleRows.slice(0, FIRST_PAGE_ROWS);
|
|
@@ -500,7 +519,7 @@ export function toHtml(audit, brand = null) {
|
|
|
500
519
|
}
|
|
501
520
|
const cardBody = (c) => [
|
|
502
521
|
// Текст карточки это отдельное предложение под заголовком: с заглавной (cimes.ru, 14.10.2026).
|
|
503
|
-
c.text ? `<p>${t(`${String(c.text)[0].toUpperCase()}${String(c.text).slice(1)}`)}</p>` : '',
|
|
522
|
+
c.text ? `<p>${t(/^(?:www\.|https?:|\/|[a-z0-9-]+\.[a-z]{2,}\b)/.test(String(c.text)) ? String(c.text) : `${String(c.text)[0].toUpperCase()}${String(c.text).slice(1)}`)}</p>` : '', // «Www.site.com» с заглавной читалось как опечатка (15.10.2026)
|
|
504
523
|
c.cost ? `<p><span class="card-tag">${L.whatItCosts}</span>${t(c.cost)}</p>` : '',
|
|
505
524
|
c.fix ? `<p><span class="card-tag">${L.theFix}</span>${t(c.fix)}</p>` : '',
|
|
506
525
|
].join('') || '<p class="card-empty">${L.noBody}</p>';
|
|
@@ -511,10 +530,14 @@ export function toHtml(audit, brand = null) {
|
|
|
511
530
|
* деньги выглядит как пустой лист. Сам факт не пропадает: он сказан в сводке.
|
|
512
531
|
*/
|
|
513
532
|
let criticalInline = '';
|
|
533
|
+
// «Critical issues, P0» только когда все пункты провалены; с частичными это «что чинить первым» (moregroup.estate,
|
|
534
|
+
// прогон на своих сайтах 15.10.2026: в «критичном» стояли две строки «Частично»).
|
|
535
|
+
const allBad = (a.critical || []).every((c) => c.level !== 'warn');
|
|
536
|
+
const CL = allBad ? L : { ...L, secCritical: L.secFirst, criticalEyebrow: L.firstEyebrow, criticalTitle: L.firstTitle };
|
|
514
537
|
if ((a.critical || []).length) {
|
|
515
538
|
// Один-два пункта на целом листе выглядят пустым листом (cimes.ru, 14.10.2026): тогда раздел встаёт в начало технической проверки.
|
|
516
|
-
if (a.critical.length <= 3) criticalInline = `<div class="eyebrow">${
|
|
517
|
-
else pages.push(`<div class="page">${header(a,
|
|
539
|
+
if (a.critical.length <= 3) criticalInline = `<div class="eyebrow">${CL.criticalEyebrow}</div><h2>${CL.criticalTitle}</h2><div class="cards">${cards}</div>`;
|
|
540
|
+
else pages.push(`<div class="page">${header(a, CL.secCritical, L)}<div class="eyebrow">${CL.criticalEyebrow}</div><h2>${CL.criticalTitle}</h2><div class="cards">${cards}</div>${footer(a, n++, preparedBy)}</div>`);
|
|
518
541
|
}
|
|
519
542
|
/*
|
|
520
543
|
* Длинная таблица режется на листы по счёту строк, а не переливается сама.
|
|
@@ -524,9 +547,14 @@ export function toHtml(audit, brand = null) {
|
|
|
524
547
|
* 11 мм, на лист влезает около восемнадцати вместе с заголовком. Строка с пояснением выше,
|
|
525
548
|
* поэтому считаем её за полторы.
|
|
526
549
|
*/
|
|
527
|
-
|
|
550
|
+
// Строки «на странице» считаются в оценке «SEO, контент и структура», а стояли в одной таблице с техникой без пометки:
|
|
551
|
+
// «SEO, техника: 15 из 15 пройдено» рядом с «Описание главной: Частично» (oper-stack.com, прогон на своих сайтах
|
|
552
|
+
// 15.10.2026). Перед ними строка-подзаголовок с названием их оценки.
|
|
553
|
+
const ru = a.meta?.lang === 'ru';
|
|
554
|
+
const onpageHead = { sub: ru ? 'На странице: эти строки считаются в оценке «SEO, контент и структура»' : 'On the page: these rows count towards the "SEO, content and structure" score' };
|
|
555
|
+
const techChecks = [...checksBy('technical'), ...(checksBy('technical').length && checksBy('onpage').length ? [onpageHead] : []), ...checksBy('onpage')];
|
|
528
556
|
const techHead = `<tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>`;
|
|
529
|
-
const rowHtml = (c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`;
|
|
557
|
+
const rowHtml = (c) => c.sub ? `<tr><th colspan="3" style="text-align:left;padding-top:3mm">${esc(c.sub)}</th></tr>` : `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L, c.value)}<td>${esc(fin(c.value))}${c.comment ? `<br><span style="color:var(--muted)">${esc(fin(cap(c.comment)))}</span>` : ''}</td></tr>`;
|
|
530
558
|
const TECH_UNITS_PER_PAGE = 17;
|
|
531
559
|
const techPages = [];
|
|
532
560
|
let bucket = [];
|
|
@@ -541,13 +569,14 @@ export function toHtml(audit, brand = null) {
|
|
|
541
569
|
// Хвост в одну-две строки не печатаем отдельным листом: он уходит на предыдущий, подгонка листа его ужмёт (cimes.ru, 14.10.2026).
|
|
542
570
|
if (techPages.length > 1 && techPages[techPages.length - 1].length <= 2) { const tail = techPages.pop(); techPages[techPages.length - 1].push(...tail); }
|
|
543
571
|
techPages.forEach((chunk, i) => {
|
|
544
|
-
|
|
545
|
-
|
|
572
|
+
// Без «(2/2)»: лист, который скрипт подгонки делит надвое, повторял тот же номер части (15.10.2026).
|
|
573
|
+
const title = L.technicalTitle;
|
|
574
|
+
pages.push(`<div class="page">${header(a, i === 0 && criticalInline ? `${CL.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>`);
|
|
546
575
|
});
|
|
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>`);
|
|
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 ?
|
|
576
|
+
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, c.value)}<td>${esc(fin(c.value))}${c.comment ? ` ${esc(fin(cap(c.comment)))}` : ''}</td></tr>`).join('')}</table>` : ''}${footer(a, n++, preparedBy)}</div>`);
|
|
577
|
+
const geoRows = [...checksBy('aeo'), ...checksBy('geo')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L, c.value)}<td>${esc(fin(c.value))}${c.comment ? ` ${esc(fin(cap(c.comment)))}` : ''}</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('');
|
|
549
578
|
const sources = [...(a.sources || []), ...(a.demand && Array.isArray(a.demand.queries) ? [demandSourceRow(a.demand, a.meta?.lang === 'ru' ? 'ru' : 'en')] : [])];
|
|
550
|
-
const checkTable = (g) => (checksBy(g).length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy(g).map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`).join('')}</table>` : '');
|
|
579
|
+
const checkTable = (g) => (checksBy(g).length ? `<table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${checksBy(g).map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L, c.value)}<td>${esc(fin(c.value))}${c.comment ? `<br><span style="color:var(--muted)">${esc(fin(cap(c.comment)))}</span>` : ''}</td></tr>`).join('')}</table>` : '');
|
|
551
580
|
/*
|
|
552
581
|
* AEO и GEO стояли на одном листе: два заголовка, два списка, две врезки и таблица сигналов.
|
|
553
582
|
* Лист переливал почти на восемьсот пикселей. Теперь по разделу на лист.
|
|
@@ -557,13 +586,13 @@ export function toHtml(audit, brand = null) {
|
|
|
557
586
|
* Таблица сигналов GEO выросла до двадцати пяти строк и на лист не влезает. Режем её, как и
|
|
558
587
|
* остальные длинные таблицы: по счёту строк, с продолжением под своим номером.
|
|
559
588
|
*/
|
|
560
|
-
const geoRowList = [...checksBy('geo'), ...checksBy('overview')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`);
|
|
589
|
+
const geoRowList = [...checksBy('geo'), ...checksBy('overview')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status, L, c.value)}<td>${esc(fin(c.value))}${c.comment ? `<br><span style="color:var(--muted)">${esc(fin(cap(c.comment)))}</span>` : ''}</td></tr>`);
|
|
561
590
|
const geoHead = `<tr><th>${L.colSignal}</th><th>${L.colStatus}</th><th>${L.colDetail}</th></tr>`;
|
|
562
591
|
const GEO_ROWS_PER_PAGE = 15;
|
|
563
592
|
const geoChunks = [];
|
|
564
593
|
for (let i = 0; i < geoRowList.length; i += GEO_ROWS_PER_PAGE) geoChunks.push(geoRowList.slice(i, i + GEO_ROWS_PER_PAGE));
|
|
565
594
|
(geoChunks.length ? geoChunks : [[]]).forEach((chunk, i, all) => {
|
|
566
|
-
const title =
|
|
595
|
+
const title = L.geoTitle;
|
|
567
596
|
const tail = i === all.length - 1 ? `<div class="callout"><p>${t(a.geo.callout)}</p></div>` : '';
|
|
568
597
|
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>`);
|
|
569
598
|
});
|
|
@@ -654,7 +683,7 @@ export function toHtml(audit, brand = null) {
|
|
|
654
683
|
for (let i = 0; i < metaFixes.length; i += METAFIX_ROWS) {
|
|
655
684
|
const chunk = metaFixes.slice(i, i + METAFIX_ROWS);
|
|
656
685
|
const part = Math.floor(i / METAFIX_ROWS) + 1;
|
|
657
|
-
const title =
|
|
686
|
+
const title = L.metaHead || 'Titles and descriptions';
|
|
658
687
|
const last = part === parts;
|
|
659
688
|
pages.push(`<div class="page handover-page">${header(a, L.secHandover || 'Files', L)}<div class="eyebrow">${esc(L.handoverEyebrow || 'Files')}</div><h2>${esc(title)}</h2><p class="hand-note">${esc(L.metaNote || '')}</p><table class="meta-fixes"><tr><th>${esc(L.colUrl)}</th><th>${esc(L.colFinding)}</th></tr>${chunk.map(rowOf).join('')}</table>${last && allFixes.length > metaFixes.length ? `<p class="scorecard-foot">${esc(L.metaMore ? L.metaMore(allFixes.length - metaFixes.length) : '')}</p>` : ''}${footer(a, n++, preparedBy)}</div>`);
|
|
660
689
|
}
|
|
@@ -683,7 +712,7 @@ export function toHtml(audit, brand = null) {
|
|
|
683
712
|
const chunk = allSampleRows.slice(i, i + APPENDIX_ROWS);
|
|
684
713
|
const part = Math.floor(i / APPENDIX_ROWS) + 1;
|
|
685
714
|
const parts = Math.ceil(allSampleRows.length / APPENDIX_ROWS);
|
|
686
|
-
const title =
|
|
715
|
+
const title = L.appendixPages || L.pagesSampled;
|
|
687
716
|
pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${esc(L.appendixEyebrow || 'Appendix')}</div><h2>${esc(title)}</h2><table class="pages appendix">${pagesHead}${chunk.map(appendixRow).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
|
|
688
717
|
}
|
|
689
718
|
}
|
|
@@ -705,14 +734,16 @@ export function toHtml(audit, brand = null) {
|
|
|
705
734
|
* странице до печати (1) переносит хвост длинной таблицы на новый лист, (2) малый перелив ужимает
|
|
706
735
|
* масштабом, не мельче 0.9, (3) перенумеровывает подвалы. Работает и у печати на сервере, потому что
|
|
707
736
|
* живёт в HTML, а не в печатающем коде. Проверка числа листов после печати: pdfPageCount.
|
|
737
|
+
* Ужимаем с запасом 24px (около 6 мм): таблица в печати раскладывается чуть выше, чем на экране, и лист, ужатый ровно
|
|
738
|
+
* до A4, переливался строкой на новый лист (pilatesvw.ru, refreshdekor.ru, svetak.ru, прогон на 20 сайтах 15.10.2026).
|
|
708
739
|
*/
|
|
709
740
|
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')})}
|
|
710
741
|
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}
|
|
742
|
+
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}if(rows.length-k<=3){var nk=Math.max(1,rows.length-Math.max(6,Math.ceil(rows.length/3)));for(var r4=nk;r4<k;r4++)rows[r4].style.display='none';k=nk}
|
|
712
743
|
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]);
|
|
713
744
|
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)});
|
|
714
745
|
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}
|
|
715
|
-
Array.prototype.forEach.call(document.querySelectorAll('.page'),function(el){var h=el.scrollHeight;if(h>H
|
|
746
|
+
Array.prototype.forEach.call(document.querySelectorAll('.page'),function(el){var h=el.scrollHeight;if(h>H-24){var z=Math.max(0.85,Math.floor((H-24)/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')}});
|
|
716
747
|
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)});
|
|
717
748
|
document.documentElement.setAttribute('data-pages',String(document.querySelectorAll('.page').length))});</script>`;
|
|
718
749
|
|
package/src/rendered.mjs
CHANGED
|
@@ -173,6 +173,10 @@ export function compareReadings(rawHtml, renderedHtml) {
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
const WORD = { ru: (n) => `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'слово' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 'слова' : 'слов'}` };
|
|
176
|
+
// После «против» родительный падеж: «против 373 слов», «против 1791 слова» (прогон на 20 сайтах, 15.10.2026).
|
|
177
|
+
const WORD_GEN = (n) => `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'слова' : 'слов'}`;
|
|
178
|
+
// Кириллический адрес показываем буквами, а не «%D0%B2…».
|
|
179
|
+
const pathOf = (u) => { try { return decodeURI(String(u || '')); } catch { return String(u || ''); } };
|
|
176
180
|
|
|
177
181
|
/** Строка проверки для аудита. Текст пишется так, чтобы владелец понял, чем ему это грозит. */
|
|
178
182
|
export function jsBlindnessCheck(pages, lang = 'en') {
|
|
@@ -196,8 +200,8 @@ export function jsBlindnessCheck(pages, lang = 'en') {
|
|
|
196
200
|
const value = worst.hiddenShare === 0
|
|
197
201
|
? (lang === 'ru' ? `без скриптов виден весь текст: проверено на ${measured.length} ${measured.length === 1 ? 'странице' : 'страницах'}` : `all the text is visible without JavaScript, checked on ${measured.length} page${measured.length === 1 ? '' : 's'}`)
|
|
198
202
|
: lang === 'ru'
|
|
199
|
-
? `без скриптов
|
|
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`;
|
|
203
|
+
? `без скриптов ${median === 0 ? 'на большинстве страниц виден весь текст' : `видно всё, кроме ${median} ${median % 10 === 1 && median % 100 !== 11 ? 'процента' : 'процентов'} текста`} (медиана по ${measured.length} ${measured.length === 1 ? 'странице' : 'страницам'}), хуже всего ${pathOf(worst.url)}: там скрыто ${worst.hiddenShare} ${worst.hiddenShare % 10 === 1 && worst.hiddenShare % 100 !== 11 ? 'процент' : [2, 3, 4].includes(worst.hiddenShare % 10) && ![12, 13, 14].includes(worst.hiddenShare % 100) ? 'процента' : 'процентов'}, ${WORD.ru(worst.raw)} без скриптов против ${WORD_GEN(worst.rendered)} в браузере (здесь считается весь текст страницы вместе с меню и подвалом, поэтому числа расходятся с таблицей страниц)`
|
|
204
|
+
: `${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 (this counts all the text on the page, menu and footer included, so it differs from the pages table)`;
|
|
201
205
|
// Слова по размеру беды: на четырнадцати процентах писать «страница почти пустая» это враньё
|
|
202
206
|
// в ту же сторону, от которого мы уходим.
|
|
203
207
|
const comment = status === 'ok'
|
package/src/tasks-pdf.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Простым языком. Владелец сайта не знает, чем открыть .md, и не перешлёт его исполнителю. PDF в
|
|
5
5
|
* оформлении отчёта открывается везде: у каждой задачи «Сейчас», «Что сделать» и «Как проверить»,
|
|
6
6
|
* и файл целиком пересылается тому, кто ведёт сайт.
|
|
7
|
+
* Правило для исполнителя стоит в начале, во вводном блоке: последней строкой оно одно уезжало на почти пустой
|
|
8
|
+
* лист (babynanny.ru, прогон на своих сайтах 15.10.2026).
|
|
7
9
|
*/
|
|
8
10
|
import { writeFileSync, existsSync, mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
9
11
|
import { spawnSync } from 'node:child_process';
|
|
@@ -12,7 +14,10 @@ import { join, resolve as resolvePath } from 'node:path';
|
|
|
12
14
|
import { agentPrompts } from './prompts.mjs';
|
|
13
15
|
import { pdfPageCount } from './render.mjs';
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
// Кириллические адреса в тексте показываем буквами: «%D0%B2%D0%B8…» исполнитель не читает, а длинная строка уходила за край листа (15.10.2026).
|
|
18
|
+
// Раскрываем только цепочки с не-ASCII байтами, служебные «%20» и «%2F» не трогаем.
|
|
19
|
+
const unpct = (s) => String(s ?? '').replace(/(?:%[0-9A-Fa-f]{2})*%[89A-Fa-f][0-9A-Fa-f](?:%[0-9A-Fa-f]{2})*/g, (m) => { try { return decodeURIComponent(m); } catch { return m; } });
|
|
20
|
+
const esc = (t) => unpct(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
16
21
|
const md = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
|
17
22
|
|
|
18
23
|
export function renderTasksHtml(audit, { lang = audit.meta?.lang || 'en' } = {}) {
|
|
@@ -21,7 +26,7 @@ export function renderTasksHtml(audit, { lang = audit.meta?.lang || 'en' } = {})
|
|
|
21
26
|
const L = ru
|
|
22
27
|
? { eb: 'OperStack · задачи для исполнителя', h1: `${host}: что поправить на сайте`, now: 'Сейчас', task: 'Что сделать', verify: 'Как проверить',
|
|
23
28
|
intro: ['Ниже список задач, по одной на каждую найденную проблему. Каждая написана обычными словами, её поймёт и разработчик, и помощник вроде ChatGPT.', 'Файл можно переслать исполнителю целиком. Каждая задача идёт вместе со строками «Сейчас» и «Как проверить»: по ним видно, откуда начинать и когда работа сделана.'],
|
|
24
|
-
rule: 'Правило для исполнителя: не
|
|
29
|
+
rule: 'Правило для исполнителя: не выдумывайте факты о бизнесе. Всё, чего нет на сайте и чего вам не сказали, спрашивайте у владельца.', foot: 'задачи' }
|
|
25
30
|
: { 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
31
|
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.'],
|
|
27
32
|
rule: 'Rule for the developer: invent no facts about the business. Anything not on the site and not told to you, ask the owner.', foot: 'tasks' };
|
|
@@ -32,7 +37,7 @@ export function renderTasksHtml(audit, { lang = audit.meta?.lang || 'en' } = {})
|
|
|
32
37
|
});
|
|
33
38
|
return `<!doctype html><html lang="${ru ? 'ru' : 'en'}"><head><meta charset="utf-8"><title>${esc(L.h1)}</title>
|
|
34
39
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
|
|
35
|
-
<style>@page{size:A4;margin:16mm 16mm 18mm}body{font:10pt/1.5 "IBM Plex Sans",sans-serif;color:#16202B;margin:0}
|
|
40
|
+
<style>@page{size:A4;margin:16mm 16mm 18mm}body{font:10pt/1.5 "IBM Plex Sans",sans-serif;color:#16202B;margin:0;overflow-wrap:anywhere}
|
|
36
41
|
.eb{font-size:7.5pt;letter-spacing:.14em;text-transform:uppercase;color:#177C70;font-weight:600;margin:0 0 6px}
|
|
37
42
|
h1{font-family:Fraunces,serif;font-size:22pt;line-height:1.15;margin:0 0 10px}
|
|
38
43
|
.intro{background:#E7F3EF;border-radius:10px;padding:10px 14px;margin:10px 0 16px}.intro p{margin:0 0 6px}
|
|
@@ -40,9 +45,9 @@ h1{font-family:Fraunces,serif;font-size:22pt;line-height:1.15;margin:0 0 10px}
|
|
|
40
45
|
h3{font-family:Fraunces,serif;font-size:12.5pt;margin:0 0 6px;display:flex;gap:8px;align-items:baseline}h3 span{font-family:"IBM Plex Sans";font-size:9pt;color:#fff;background:#177C70;border-radius:10px;padding:1px 7px}
|
|
41
46
|
p{margin:0 0 5px}em{font-style:normal;font-weight:600;font-size:8pt;text-transform:uppercase;letter-spacing:.06em;color:#5A6470;display:block}
|
|
42
47
|
.now{color:#3F4854}.chk{color:#0F5C53}.rule{margin-top:14px;font-size:9pt;color:#5A6470}</style></head><body>
|
|
43
|
-
<p class="eb">${esc(L.eb)}</p><h1>${esc(L.h1)}</h1><div class="intro">${L.intro.map((x) => `<p>${esc(x)}</p>`).join('')}</div>
|
|
48
|
+
<p class="eb">${esc(L.eb)}</p><h1>${esc(L.h1)}</h1><div class="intro">${L.intro.map((x) => `<p>${esc(x)}</p>`).join('')}<p><b>${esc(L.rule)}</b></p></div>
|
|
44
49
|
${items.join('\n')}
|
|
45
|
-
|
|
50
|
+
</body></html>`;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
function findChrome() {
|
package/src/verify.mjs
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* «нет», строка переписывается по факту, а в audit.verification записывается, что и почему
|
|
10
10
|
* поменялось. Отчёт без этой записи считается непроверенным (см. assertVerified).
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import { chatWidgetOf, MESSENGER_HREF } from './client-eye.mjs';
|
|
13
|
+
import { __readSitemap as readSitemap, policyKinds, policyKindsByText, ABOUT_PATH, ABOUT_TEXT, computeScores, computeOverall } from './collect.mjs';
|
|
13
14
|
import { localiseChecks } from './i18n.mjs';
|
|
14
15
|
import { detectNiche } from './niche.mjs';
|
|
15
16
|
import { MECHANICAL_IDS } from './narrative.mjs';
|
|
@@ -18,16 +19,18 @@ import { request as httpsRequest } from 'node:https';
|
|
|
18
19
|
import { request as httpRequest } from 'node:http';
|
|
19
20
|
|
|
20
21
|
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130 Safari/537.36';
|
|
21
|
-
const MESSENGER =
|
|
22
|
+
const MESSENGER = MESSENGER_HREF;
|
|
22
23
|
// Соцсети и отраслевые каталоги: для врача профиль на ПроДокторов важнее соцсетей (14.10.2026, neronov.pro).
|
|
23
24
|
const SOCIAL = /\/\/(?:www\.)?(vk\.com|ok\.ru|t\.me|instagram\.com|facebook\.com|youtube\.com|rutube\.ru|dzen\.ru|linkedin\.com|tiktok\.com|x\.com|twitter\.com|pinterest\.com|prodoctorov\.ru|napopravku\.ru|docdoc\.ru|sberhealth\.ru|zoon\.ru|2gis\.ru|yandex\.ru\/maps\/org|yandex\.ru\/profile|google\.com\/maps|g\.page|yell\.ru|profi\.ru|avito\.ru\/brands|trustpilot\.com|yelp\.com|g2\.com|clutch\.co)\/[^"'\s?#]+/gi;
|
|
24
|
-
const ABOUT_HREF =
|
|
25
|
+
const ABOUT_HREF = ABOUT_PATH;
|
|
25
26
|
const ABOUT_TITLE = /^(о нас|о компании|о клинике|о центре|обо мне|о себе|об авторе|о проекте|о враче|о докторе|о специалисте|о практике|about|about us|about me|about the doctor|our practice|our story|who we are)(?![\p{L}])/iu;
|
|
26
27
|
const POLICY_TEXT = { privacy: /политик[аи] (?:конфиденциальности|обработки персональных данных)|privacy policy/i, terms: /пользовательское соглашение|публичная оферта|договор(?:-| )оферт|правила (?:и порядок )?оказания (?:платных )?услуг|terms (?:of (?:service|use)|and conditions)/i };
|
|
27
28
|
const PRICE = /(?:\d[\d\s ]{0,9}(?:[.,]\d{1,2})?\s?(?:₽|руб(?:\.|лей|ля|ль)?(?![а-яё])|р\.(?=\s))|(?:\$|€|£)\s?\d[\d,]*(?:\.\d{2})?|"price"\s*:\s*"?\d)/i;
|
|
28
29
|
|
|
29
30
|
const strip = (html) => String(html || '').replace(/<(script|style|noscript|template)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/ | /g, ' ').replace(/\s+/g, ' ');
|
|
30
31
|
const hrefs = (html) => [...String(html || '').matchAll(/href\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
|
|
32
|
+
// Ссылки вместе с текстом: правовую страницу и страницу о компании узнаём и по подписи ссылки (15.10.2026).
|
|
33
|
+
const anchors = (html) => [...String(html || '').matchAll(/<a\b[^>]*?href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)].map((m) => ({ href: m[1], text: strip(m[2]).trim() }));
|
|
31
34
|
const titleOf = (html) => strip((String(html).match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '').trim();
|
|
32
35
|
// H1 внутри <template> и с атрибутами Vue (v-, :, @) человек не видит: как в сборщике (collect.mjs).
|
|
33
36
|
const h1sOf = (html) => [...String(html).replace(/<template[\s\S]*?<\/template>/gi, ' ').matchAll(/<h1\b([^>]*)>[\s\S]*?<\/h1>/gi)].filter((m) => !/\s(?:v-|:|@|x-)[\w-]+=/.test(m[1] || '')).map((m) => strip(m[0]).trim()).filter((t) => t && !/\{\{|\}\}/.test(t));
|
|
@@ -161,6 +164,7 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
161
164
|
if (pol && pol.status !== 'ok') {
|
|
162
165
|
const linked = new Set(); const inText = {};
|
|
163
166
|
for (const p of pages) {
|
|
167
|
+
for (const x of anchors(p.html)) for (const k of [...policyKinds(x.href), ...policyKindsByText(x.text)]) linked.add(k);
|
|
164
168
|
for (const h of hrefs(p.html)) for (const k of policyKinds(h)) linked.add(k);
|
|
165
169
|
const t = strip(p.html);
|
|
166
170
|
for (const [k, re] of Object.entries(POLICY_TEXT)) if (re.test(t) && !inText[k]) inText[k] = p.url;
|
|
@@ -188,23 +192,30 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
188
192
|
if (about && about.status !== 'ok') {
|
|
189
193
|
// Только свои ссылки: «yandex.ru/support/.../about» не страница о компании (cimes.ru, 14.10.2026).
|
|
190
194
|
const ownHref = (h, base) => { try { return new URL(h, base).host.replace(/^www\./, '') === new URL(base).host.replace(/^www\./, ''); } catch { return false; } };
|
|
191
|
-
|
|
192
|
-
|
|
195
|
+
// Называем саму страницу о компании, а не страницу, где нашлась ссылка: «найдено на /» при живой /obo-mne читалось
|
|
196
|
+
// как ошибка (babynanny.ru, 15.10.2026).
|
|
197
|
+
const ownPage = pages.find((p) => 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)));
|
|
198
|
+
let target = ownPage ? ownPage.url : '';
|
|
199
|
+
if (!target) for (const p of pages) { const a = anchors(p.html).find((x) => ownHref(x.href, p.url) && (ABOUT_HREF.test(x.href) || ABOUT_TEXT.test(x.text))); if (a) { try { target = new URL(a.href, p.url).href; } catch { /* битый адрес */ } break; } }
|
|
200
|
+
const hit = target ? { url: target } : null;
|
|
201
|
+
const shown = (u) => { try { return decodeURI(path(u)); } catch { return path(u); } };
|
|
202
|
+
if (hit) change('trust-about', { status: 'ok', value: ru ? `есть: ${shown(hit.url)}` : `present: ${shown(hit.url)}` }, { url: hit.url });
|
|
193
203
|
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 });
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
// 3. Мессенджеры.
|
|
197
207
|
const msg = byId('conv-messenger');
|
|
198
208
|
// Онлайн-чат на сайте это тоже способ написать (14.10.2026, brainmd.com: Gorgias на каждой странице).
|
|
199
|
-
|
|
200
|
-
const chatPages = pages.filter((p) =>
|
|
209
|
+
// Список чатов общий с проверкой глазами клиента (client-eye.mjs): в отчёт идёт название чата, а не адрес загрузчика.
|
|
210
|
+
const chatPages = pages.filter((p) => chatWidgetOf(p.html));
|
|
201
211
|
if (msg && msg.status !== 'ok' && chatPages.length) {
|
|
202
|
-
const name = (chatPages[0].html
|
|
203
|
-
change('conv-messenger', { status: 'ok', value: ru ? `мессенджеров нет, но на ${chatPages.length} из ${pages.length} страниц есть онлайн-чат (${name})` : `no messenger links, but ${chatPages.length} of ${pages.length} pages carry a live chat (${name})`, comment: '' }, { chat: name });
|
|
212
|
+
const name = chatWidgetOf(chatPages[0].html);
|
|
213
|
+
change('conv-messenger', { status: chatPages.length >= pages.length * 0.8 ? 'ok' : 'warn', value: ru ? `мессенджеров нет, но на ${chatPages.length} из ${pages.length} страниц есть онлайн-чат (${name})` : `no messenger links, but ${chatPages.length} of ${pages.length} pages carry a live chat (${name})`, comment: '' }, { chat: name });
|
|
204
214
|
}
|
|
205
215
|
if (msg && msg.status !== 'ok') {
|
|
206
216
|
const hit = pages.filter((p) => hrefs(p.html).some((h) => MESSENGER.test(h)));
|
|
207
|
-
|
|
217
|
+
// «Норма» только если мессенджер почти на всех страницах: «есть на 1 из 150» со статусом «Норма» (etochay.ru, 16.10.2026).
|
|
218
|
+
if (hit.length) change('conv-messenger', { status: hit.length >= pages.length * 0.8 ? 'ok' : 'warn', value: ru ? `есть на ${hit.length} из ${pages.length} прочитанных страниц` : `on ${hit.length} of ${pages.length} pages read` }, { urls: hit.slice(0, 5).map((p) => p.url) });
|
|
208
219
|
else change('conv-messenger', { value: ru ? `нет ни на одной из ${pages.length} прочитанных страниц` : `none on any of the ${pages.length} pages read`, comment: ru ? 'человеку с телефона одна ссылка на мессенджер часто удобнее формы' : 'for a visitor on a phone, one messenger link is often easier than a form' }, { pagesRead: pages.length });
|
|
209
220
|
}
|
|
210
221
|
|
|
@@ -219,7 +230,7 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
219
230
|
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 { /* мусор */ } }
|
|
220
231
|
for (const m of String(p.html).matchAll(SOCIAL)) inLinks.add(m[1].toLowerCase());
|
|
221
232
|
}
|
|
222
|
-
if (inSchema.size) change('trust-profiles', { status: inSchema.size >= 2 ? 'ok' : 'warn', value: profileLine([...inSchema]
|
|
233
|
+
if (inSchema.size) change('trust-profiles', { status: inSchema.size >= 2 ? 'ok' : 'warn', value: profileLine([...inSchema], ru ? 'ru' : 'en') }, { inSchema: [...inSchema] });
|
|
223
234
|
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] });
|
|
224
235
|
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 });
|
|
225
236
|
}
|
|
@@ -246,7 +257,8 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
246
257
|
for (let i = 0; i < 5; i++) { const r = await coldTtfb(audit.meta.site); if (r && r.ms) runs.push(r.ms); }
|
|
247
258
|
if (runs.length >= 3) {
|
|
248
259
|
const mid = median(runs); const lo = Math.min(...runs); const hi = Math.max(...runs);
|
|
249
|
-
|
|
260
|
+
// До секунды две цифры после запятой: «0,8 с» при статусе «частично» читалось как противоречие с порогом 0,8 с (babynanny.ru).
|
|
261
|
+
const fmt = (x) => { const d = x < 1000 ? 2 : 1; return ru ? `${(x / 1000).toFixed(d).replace('.', ',')} с` : `${(x / 1000).toFixed(d)} s`; };
|
|
250
262
|
// Пороги web.dev: до 0,8 с хорошо, до 1,8 с терпимо, дальше плохо.
|
|
251
263
|
const status = mid <= 800 ? 'ok' : mid <= 1800 ? 'warn' : 'bad';
|
|
252
264
|
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 });
|
|
@@ -254,7 +266,8 @@ export async function secondLook(audit, { maxPages = 150, concurrency = 4, fetch
|
|
|
254
266
|
}
|
|
255
267
|
}
|
|
256
268
|
const llms = byId('llms');
|
|
257
|
-
|
|
269
|
+
// Перепроверяем только «файла нет»: «Частично» из-за ссылок на чужие сайты это не отсутствие файла (15.10.2026).
|
|
270
|
+
if (llms && llms.status !== 'ok' && /not present|^нет$/i.test(String(llms.value || ''))) {
|
|
258
271
|
const r = await fetchPage(`${new URL(audit.meta.site).origin}/llms.txt`, fetchImpl, 15000);
|
|
259
272
|
if (r.ok && r.html.trim() && !/<html/i.test(r.html)) change('llms', { status: 'ok', value: ru ? 'есть' : 'present' }, { status: r.status });
|
|
260
273
|
}
|