@operstack/audit 0.3.3 → 0.5.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/bin/audit.mjs +3 -3
- package/package.json +3 -2
- package/src/collect.mjs +7 -4
- package/src/i18n.mjs +152 -0
- package/src/index.mjs +2 -1
- package/src/render.mjs +80 -24
- package/src/test-i18n.mjs +55 -0
package/bin/audit.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* OperStack audit generator.
|
|
4
4
|
*
|
|
5
|
-
* operstack-audit collect https://example.com [--pages 20] [--out audit.json] [--backlinks links.json]
|
|
5
|
+
* operstack-audit collect https://example.com [--pages 20] [--out audit.json] [--backlinks links.json] [--lang ru]
|
|
6
6
|
* fetches the public signals (robots, sitemaps, llms.txt, sampled pages) and writes
|
|
7
7
|
* audit.json with every technical row filled and the narrative fields left for the analyst
|
|
8
8
|
* operstack-audit render audit.json [--out report.html] [--pdf]
|
|
@@ -23,14 +23,14 @@ const opt = (k, d) => { const i = rest.indexOf(k); return i === -1 ? d : rest[i
|
|
|
23
23
|
const has = (k) => rest.includes(k);
|
|
24
24
|
|
|
25
25
|
if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
26
|
-
console.log('Usage:\n operstack-audit collect <url> [--pages 20] [--out audit.json] [--backlinks links.json]\n operstack-audit render <audit.json> [--out report.html] [--pdf]\n operstack-audit check <audit.json>');
|
|
26
|
+
console.log('Usage:\n operstack-audit collect <url> [--pages 20] [--out audit.json] [--backlinks links.json] [--lang ru]\n operstack-audit render <audit.json> [--out report.html] [--pdf]\n operstack-audit check <audit.json>');
|
|
27
27
|
process.exit(cmd ? 0 : 2);
|
|
28
28
|
}
|
|
29
29
|
if (cmd === 'collect') {
|
|
30
30
|
if (!/^https?:\/\//.test(target || '')) { console.error('collect needs an absolute URL'); process.exit(2); }
|
|
31
31
|
const backlinksFile = opt('--backlinks', '');
|
|
32
32
|
const backlinks = backlinksFile ? JSON.parse(readFileSync(resolve(backlinksFile), 'utf8')) : null;
|
|
33
|
-
const audit = await collect(target, { pages: Number(opt('--pages', 20)), backlinks, log: (m) => console.error(` ${m}`) });
|
|
33
|
+
const audit = await collect(target, { pages: Number(opt('--pages', 20)), backlinks, lang: opt('--lang', 'en'), log: (m) => console.error(` ${m}`) });
|
|
34
34
|
const out = resolve(opt('--out', 'audit.json'));
|
|
35
35
|
writeFileSync(out, JSON.stringify(audit, null, 2));
|
|
36
36
|
console.log(`wrote ${out}: ${audit.checks.length} checks, ${audit.sample.length} pages sampled, scores ${Object.entries(audit.scores).map(([k, v]) => `${k} ${v === null ? 'not measured' : v}`).join(', ')}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"operstack-audit": "bin/audit.mjs"
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
|
-
".": "./src/index.mjs"
|
|
12
|
+
".": "./src/index.mjs",
|
|
13
|
+
"./i18n": "./src/i18n.mjs"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"bin",
|
package/src/collect.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* No Search Console, no analytics. The narrative fields are left for the analyst.
|
|
5
5
|
*/
|
|
6
6
|
import { parse } from 'node-html-parser';
|
|
7
|
+
import { localiseChecks } from './i18n.mjs';
|
|
7
8
|
|
|
8
9
|
const UA = 'Mozilla/5.0 (compatible; OperStackAudit/0.1; +https://oper-stack.com)';
|
|
9
10
|
|
|
@@ -145,7 +146,7 @@ async function readSitemap(url, seen = new Set(), depth = 0) {
|
|
|
145
146
|
return out;
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null } = {}) {
|
|
149
|
+
export async function collect(startUrl, { pages = 20, log = () => {}, backlinks = null, lang = 'en' } = {}) {
|
|
149
150
|
const opts = { backlinks };
|
|
150
151
|
const origin = new URL(startUrl).origin;
|
|
151
152
|
const host = new URL(startUrl).host;
|
|
@@ -317,17 +318,19 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
317
318
|
checks.push(row('conv-messenger', 'conversion', 'A messenger link', msg.length ? 'ok' : 'warn', msg.length ? `on ${msg.length} of ${pagesForTrust.length} sampled page(s)` : 'none on the sampled pages', msg.length ? '' : 'for an international audience reading on a phone, one messenger link is usually worth more than a form'));
|
|
318
319
|
|
|
319
320
|
const { scores, scoreBasis } = computeScores(checks);
|
|
321
|
+
// Оценки считаются до перевода: язык на цифры не влияет.
|
|
322
|
+
const localised = localiseChecks(checks, lang);
|
|
320
323
|
const critical = checks.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' }));
|
|
321
324
|
|
|
322
325
|
return {
|
|
323
|
-
meta: { site: home.final, host, collectedAt: new Date().toISOString(), tool: '@operstack/audit 0.1.0', auditType: 'External audit (no Search Console or analytics access)', language: hp ? (hp.og?.locale || '') : '' },
|
|
326
|
+
meta: { site: home.final, host, collectedAt: new Date().toISOString(), tool: '@operstack/audit 0.1.0', auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
|
|
324
327
|
client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: 'OperStack' },
|
|
325
328
|
scores,
|
|
326
329
|
scoreBasis,
|
|
327
330
|
summary: { lead: '{{Three sentences: what the site is, what works, what holds it back.}}', verdict: '{{Key takeaway in three sentences, ending with how fast the critical issues can be fixed.}}', priorities: ['{{Priority one}}', '{{Priority two}}', '{{Priority three}}'] },
|
|
328
331
|
overview: { rows: [['CMS / stack', hp?.generator || '{{stack}}'], ['Pages in sitemap', String(sitemapPages.length)], ['Language', '{{language}}'], ['What is sold', '{{products}}']], note: '{{One paragraph on how the offer is structured on the site and whether it is clear.}}' },
|
|
329
|
-
critical,
|
|
330
|
-
checks,
|
|
332
|
+
critical: lang === 'ru' ? localised.filter((c) => c.status === 'bad').map((c) => ({ title: c.label, text: `${c.value}${c.comment ? `. ${c.comment}` : ''}`, level: 'bad' })) : critical,
|
|
333
|
+
checks: localised,
|
|
331
334
|
content: { strengths: ['{{strength}}'], weaknesses: ['{{weakness}}'], gaps: ['{{missing landing page or cluster}}'] },
|
|
332
335
|
aeo: { works: ['{{what already works}}'], blocks: ['{{what blocks answer-engine wins}}'], recommendation: '{{One recommendation with a page name.}}' },
|
|
333
336
|
geo: { rows: [['Entity clarity', '{{status}}', '{{detail}}'], ['Third-party mentions', '{{status}}', '{{detail}}'], ['Reviews and PR', '{{status}}', '{{detail}}']], callout: '{{Brand disambiguation note or the single biggest GEO risk.}}' },
|
package/src/i18n.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Перевод собранных проверок на русский.
|
|
3
|
+
*
|
|
4
|
+
* Отдельный слой поверх сборщика: логика проверок не трогается вообще, поэтому перевод не может
|
|
5
|
+
* сломать ни одну оценку. Каждая запись знает идентификатор проверки и умеет пересобрать её текст
|
|
6
|
+
* по числам, которые уже посчитаны. Если для проверки записи нет, она остаётся как есть, и тест
|
|
7
|
+
* это показывает, а не прячет.
|
|
8
|
+
*
|
|
9
|
+
* import { localiseChecks } from '@operstack/audit/i18n'
|
|
10
|
+
* audit.checks = localiseChecks(audit.checks, 'ru')
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Достаёт все целые и дробные числа из строки по порядку. */
|
|
14
|
+
const nums = (s) => (String(s).match(/\d+(?:[.,]\d+)?/g) || []).map((x) => x.replace(',', '.'));
|
|
15
|
+
/** Склонение: 1 страница, 2 страницы, 5 страниц. */
|
|
16
|
+
const plural = (n, one, few, many) => {
|
|
17
|
+
const d = Math.abs(n) % 10; const dd = Math.abs(n) % 100;
|
|
18
|
+
return `${n} ${d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 10 || dd >= 20) ? few : many}`;
|
|
19
|
+
};
|
|
20
|
+
const pages = (n) => plural(Number(n), 'страница', 'страницы', 'страниц');
|
|
21
|
+
const pagesOf = (a, b) => `${a} из ${pages(b)}`;
|
|
22
|
+
|
|
23
|
+
/** Перевод «сколько проверок пройдено» из подписи под оценкой. */
|
|
24
|
+
export function localiseBasisNote(note) {
|
|
25
|
+
const m = String(note || '').match(/^(\d+) of (\d+) checks pass(?:, (\d+) needs? attention)?(?:, (\d+) fails?)?$/);
|
|
26
|
+
if (!m) {
|
|
27
|
+
if (/^not measured/.test(String(note || ''))) return String(note).replace(/^not measured:\s*/, 'не измерялось: ');
|
|
28
|
+
return note;
|
|
29
|
+
}
|
|
30
|
+
const [, ok, total, warn, bad] = m;
|
|
31
|
+
const parts = [`${ok} из ${plural(Number(total), 'проверки', 'проверок', 'проверок')} пройдено`];
|
|
32
|
+
if (warn) parts.push(plural(Number(warn), 'спорная', 'спорные', 'спорных'));
|
|
33
|
+
if (bad) parts.push(plural(Number(bad), 'провалена', 'провалены', 'провалено'));
|
|
34
|
+
return parts.join(', ');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Записи перевода. value и comment получают исходный английский текст и возвращают русский,
|
|
39
|
+
* пересобирая его из тех же чисел. Там, где текст постоянный, возвращается константа.
|
|
40
|
+
*/
|
|
41
|
+
const RU = {
|
|
42
|
+
https: { label: 'HTTPS', value: (v) => (/certificate active/.test(v) ? 'сертификат активен' : 'сайт отдаётся по http, без шифрования') },
|
|
43
|
+
www: {
|
|
44
|
+
label: 'Склейка адреса с www',
|
|
45
|
+
value: (v) => (/redirects to/.test(v) ? 'адрес с www перенаправляется на основной'
|
|
46
|
+
: /does not resolve/.test(v) ? 'адрес с www не существует'
|
|
47
|
+
: `адрес с www отвечает ${nums(v)[0] || ''} и не перенаправляет`),
|
|
48
|
+
},
|
|
49
|
+
ttfb: { label: 'Время до первого байта', value: (v) => `${nums(v)[0]} мс`, comment: () => 'один запрос от проверяющего, а не лабораторный тест' },
|
|
50
|
+
robots: {
|
|
51
|
+
label: 'robots.txt',
|
|
52
|
+
value: (v) => { const n = nums(v); return /HTTP/.test(v) ? `не отдаётся, код ${n[0]}` : `${plural(Number(n[0]), 'запрещающее правило', 'запрещающих правила', 'запрещающих правил')} в ${plural(Number(n[1]), 'блоке', 'блоках', 'блоках')} агентов, ${plural(Number(n[2]), 'строка', 'строки', 'строк')} с картой сайта`; },
|
|
53
|
+
comment: (c) => (/no Sitemap/.test(c) ? 'нет строки Sitemap' : ''),
|
|
54
|
+
},
|
|
55
|
+
'robots-block': { label: 'robots.txt закрывает весь сайт', value: () => 'Disallow: / под User-agent: *', comment: () => 'поисковым системам сказано не обходить ничего' },
|
|
56
|
+
'ai-search-access': {
|
|
57
|
+
label: 'Доступ поисковых роботов ИИ',
|
|
58
|
+
value: (v) => (/every AI search fetcher/.test(v) ? 'все поисковые роботы ИИ могут читать сайт' : `закрыто: ${v.replace(/^\d+ blocked: /, '')}`),
|
|
59
|
+
comment: (c) => (c ? 'именно эти роботы достают страницу, чтобы процитировать её в ответе' : ''),
|
|
60
|
+
},
|
|
61
|
+
'robots-ai': {
|
|
62
|
+
label: 'Обучающие краулеры закрыты (решение владельца)',
|
|
63
|
+
value: (v) => `${plural(Number(nums(v)[0]), 'агент закрыт', 'агента закрыты', 'агентов закрыто')} полностью: ${v.split(': ').slice(1).join(': ')}`,
|
|
64
|
+
comment: () => 'это выбор владельца, а не дефект: эти системы не будут учиться на сайте и не процитируют его из собственного обхода',
|
|
65
|
+
},
|
|
66
|
+
sitemap: {
|
|
67
|
+
label: 'Карта сайта XML',
|
|
68
|
+
value: (v) => (/URL\(s\) in/.test(v) ? `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')} в карте сайта` : /not found|missing/i.test(v) ? 'не найдена' : `не отдаётся, код ${nums(v)[0]}`),
|
|
69
|
+
},
|
|
70
|
+
'sitemap-foreign': { label: 'В карте сайта чужие хосты', value: (v) => `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')} на других хостах` },
|
|
71
|
+
'sitemap-health': { label: 'Адреса из карты сайта отвечают 200', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} проверенных адресов отвечают 200${/redirect/.test(v) ? `, ${n[2]} перенаправляют` : ''}${/broken/.test(v) ? ', есть битые' : ''}`; } },
|
|
72
|
+
llms: {
|
|
73
|
+
label: 'llms.txt',
|
|
74
|
+
value: (v) => (/not present/.test(v) ? 'нет'
|
|
75
|
+
: /HTML page/.test(v) ? 'отдаёт HTML-страницу вместо текстового индекса'
|
|
76
|
+
: `${plural(Number(nums(v)[0]), 'ссылка', 'ссылки', 'ссылок')}, ${nums(v)[1]} на другие хосты`),
|
|
77
|
+
comment: (c) => (/nothing to read/.test(c) ? 'ответным системам нечего читать, генерация индекса это работа на день' : c ? 'система ИИ может принять сайт за чужой' : ''),
|
|
78
|
+
},
|
|
79
|
+
title: { label: 'Заголовок главной', value: (v) => { const n = nums(v); return `${plural(Number(n[n.length - 1]), 'знак', 'знака', 'знаков')}${Number(n[n.length - 1]) > 60 ? ', в выдаче обрежется' : ', укладывается в выдачу'}`; } },
|
|
80
|
+
description: { label: 'Описание главной', value: (v) => (/missing|none/i.test(v) ? 'нет' : /shortcode|garbage/i.test(v) ? 'содержит служебный код вместо текста' : `${plural(Number(nums(v)[0]), 'знак', 'знака', 'знаков')}`) },
|
|
81
|
+
h1: { label: 'Заголовок H1 на главной', value: (v) => `${plural(Number(nums(v)[0] || 0), 'заголовок', 'заголовка', 'заголовков')} H1` },
|
|
82
|
+
canonical: { label: 'Канонический адрес', value: (v) => (/missing|none/i.test(v) ? 'не указан' : 'указан') },
|
|
83
|
+
viewport: { label: 'Мобильный viewport', value: (v) => (/missing/.test(v) ? 'не задан' : v), comment: (c) => (c ? 'запрещает масштабирование: плохо для доступности и мобильного поиска' : '') },
|
|
84
|
+
og: { label: 'Карточки для соцсетей', value: (v) => (/none/.test(v) ? 'нет' : v) },
|
|
85
|
+
schema: { label: 'Разметка на главной', value: (v) => (/no JSON-LD/.test(v) ? 'разметки нет' : /invalid/.test(v) ? 'разметка есть, но с ошибкой' : v) },
|
|
86
|
+
'faq-schema': { label: 'Разметка вопросов FAQPage', value: (v) => (/not found/.test(v) ? 'не найдена в выборке' : `есть на ${pages(nums(v)[0])} выборки`) },
|
|
87
|
+
'org-schema': { label: 'Разметка организации', value: (v) => (/not found/.test(v) ? 'не найдена: ответным системам не к чему привязать сайт' : 'есть') },
|
|
88
|
+
resources: { label: 'Вес главной страницы', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'файл скриптов', 'файла скриптов', 'файлов скриптов')}, ${plural(Number(n[1]), 'стиль', 'стиля', 'стилей')}, ${plural(Number(n[2]), 'изображение', 'изображения', 'изображений')}`; } },
|
|
89
|
+
hreflang: { label: 'hreflang', value: (v) => (/none/.test(v) ? 'нет, сайт одноязычный' : `${plural(Number(nums(v)[0]), 'альтернатива', 'альтернативы', 'альтернатив')}`) },
|
|
90
|
+
cms: { label: 'Движок сайта', value: (v) => v },
|
|
91
|
+
xmlrpc: { label: 'xmlrpc.php', value: (v) => `отвечает ${nums(v)[0]}`, comment: () => 'открытая точка для атак, пользы для поиска никакой' },
|
|
92
|
+
alt: { label: 'Подписи к картинкам', value: (v) => { const n = nums(v); return `${n[0]} из ${n[1]} картинок имеют подпись, это ${n[2]} процентов, на ${pages(n[3])} выборки`; } },
|
|
93
|
+
'h1-sample': { label: 'Структура заголовков', value: (v) => (/one H1 per page/.test(v) ? 'по одному H1 на страницу' : `${nums(v)[0]} из ${pages(nums(v)[1])} имеют больше одного H1`) },
|
|
94
|
+
'dup-titles': { label: 'Повторы заголовков', value: (v) => (/its own title/.test(v) ? 'у каждой проверенной страницы свой заголовок' : `${plural(Number(nums(v)[0]), 'заголовок повторяется', 'заголовка повторяются', 'заголовков повторяются')} на разных страницах`) },
|
|
95
|
+
thin: { label: 'Тонкие страницы', value: (v) => (/median/.test(v) ? `медиана ${plural(Number(nums(v)[0]), 'слово', 'слова', 'слов')} на страницу` : `${nums(v)[0]} из ${pages(nums(v)[1])} короче 300 слов`) },
|
|
96
|
+
utility: { label: 'Служебные страницы в карте сайта', value: (v) => `${plural(Number(nums(v)[0]), 'адрес', 'адреса', 'адресов')}, например ${v.split('such as ')[1] || ''}`, comment: () => 'корзина, избранное, теги и страницы авторов засоряют индекс' },
|
|
97
|
+
'answer-first': {
|
|
98
|
+
label: 'Ответ в первом абзаце',
|
|
99
|
+
value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки начинаются с 20-90 слов, где есть цифра, сразу после H1`; },
|
|
100
|
+
comment: (c) => (c ? 'именно этот абзац ответная система забирает в цитату' : ''),
|
|
101
|
+
},
|
|
102
|
+
sections: { label: 'Разбивка на разделы', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки имеют три и более подзаголовка H2`; }, comment: (c) => (c ? 'системы цитируют разделы, а не сплошной текст' : '') },
|
|
103
|
+
tables: { label: 'Таблицы в тексте', value: (v) => (/no table/.test(v) ? 'на проверенных страницах таблиц нет' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки используют таблицу`), comment: (c) => (c ? 'таблица это второй по частоте формат цитирования после первого абзаца' : '') },
|
|
104
|
+
sources: { label: 'Названные источники цифр', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки называют, откуда взята цифра, словами или ссылкой рядом с ней`; }, comment: (c) => (c ? 'цифра без источника это первое, что система выбрасывает' : '') },
|
|
105
|
+
dates: { label: 'Открытые даты публикации', value: (v) => (/no article dates/.test(v) ? 'дат в выборке нет: ответные системы не поймут, что здесь актуально' : `${nums(v)[0]} из ${pages(nums(v)[1])} выборки показывают дату`) },
|
|
106
|
+
'links-profile': { label: 'Профиль внешних ссылок (необязательное дополнение)', value: (v) => { const n = nums(v); return `${plural(Number(n[0]), 'ссылающийся домен', 'ссылающихся домена', 'ссылающихся доменов')}, ${plural(Number(n[1]), 'ссылка', 'ссылки', 'ссылок')}, ${n[2]} процентов передают вес, показатель авторитета ${n[3]} из 100`; }, comment: (c) => `${c.split('.')[0]}. Платный инструмент, для этого отчёта не обязателен и ни в одну оценку не входит.` },
|
|
107
|
+
'trust-entity': { label: 'Кто это, в машиночитаемом виде', value: (v) => (/no postal address/.test(v) ? 'почтового адреса в разметке нет' : 'почтовый адрес опубликован в разметке'), comment: (c) => (c ? 'рейтинговые системы и ответные движки по нему отличают одну компанию от другой' : '') },
|
|
108
|
+
'trust-contact': {
|
|
109
|
+
label: 'Прямые контакты',
|
|
110
|
+
value: (v) => (/no phone, email or messenger/.test(v) ? 'ни телефона, ни почты, ни мессенджера не опубликовано'
|
|
111
|
+
: v.replace(/\bphone\b/g, 'телефон').replace(/\bemail\b/g, 'почта').replace(/a messenger/g, 'мессенджер').replace(/ and /g, ' и ')),
|
|
112
|
+
},
|
|
113
|
+
'trust-profiles': { label: 'Профили на других площадках', value: (v) => (/no sameAs/.test(v) ? 'в разметке не заявлено ни одного профиля на других площадках' : `${nums(v)[0]}: ${v.split(': ').slice(1).join(': ')}`), comment: (c) => (c ? 'самый дешёвый внешний сигнал, и он бесплатный' : '') },
|
|
114
|
+
'trust-about': { label: 'Страница о компании', value: (v) => (/no about/.test(v) ? 'страницы о компании нет или на неё не ведут ссылки' : 'на неё ведут ссылки с проверенных страниц') },
|
|
115
|
+
'trust-policies': { label: 'Политика и условия', value: (v) => (/neither/.test(v) ? 'ни политики конфиденциальности, ни условий на сайте нет' : `есть: ${v.replace(/privacy/g, 'политика').replace(/terms/g, 'условия').replace(/legal/g, 'правовая страница').replace(/cookie/g, 'куки').replace(/disclaimer/g, 'оговорка')}`) },
|
|
116
|
+
'conv-contact-path': { label: 'Способ связаться', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки дают форму, контактную ссылку или страницу контактов`; } },
|
|
117
|
+
'conv-cta': { label: 'Призыв к действию в первом экране', value: (v) => { const n = nums(v); return `${n[0]} из ${pages(n[1])} выборки ставят его в первых ${n[2]} процентах страницы`; }, comment: (c) => (c ? 'читателю, которого убедили сверху, не должно приходиться искать' : '') },
|
|
118
|
+
'conv-form-depth': { label: 'На какой глубине лежит форма', value: (v) => (/no form/.test(v) ? 'на проверенных страницах формы нет' : /could not be measured/.test(v) ? 'положение формы измерить не удалось' : `медиана ${nums(v)[0]} процентов вниз по странице, измерено на ${pages(nums(v)[1])}`), comment: (c) => (c ? 'на длинной странице дотуда почти никто не доходит, особенно с телефона' : '') },
|
|
119
|
+
'conv-form-fields': { label: 'Сколько полей просит форма', value: (v) => `медиана ${plural(Number(nums(v)[0]), 'поле', 'поля', 'полей')} до отправки`, comment: (c) => (c ? 'каждое лишнее поле стоит ответов, а телефон дороже всех' : '') },
|
|
120
|
+
'conv-messenger': { label: 'Ссылка на мессенджер', value: (v) => (/none/.test(v) ? 'на проверенных страницах нет' : `на ${nums(v)[0]} из ${pages(nums(v)[1])} выборки`), comment: (c) => (c ? 'для международной аудитории с телефона одна ссылка на мессенджер обычно ценнее формы' : '') },
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Проверки, для которых перевода нет. Пусто означает, что покрыто всё. */
|
|
124
|
+
export function untranslated(checks) {
|
|
125
|
+
return [...new Set((checks || []).map((c) => c.id).filter((id) => !RU[id]))];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Возвращает копию списка проверок с русскими названиями и текстами.
|
|
130
|
+
* Проверка без записи возвращается как есть: молча английский текст не прячем.
|
|
131
|
+
*/
|
|
132
|
+
export function localiseChecks(checks, lang = 'ru') {
|
|
133
|
+
if (lang !== 'ru') return checks;
|
|
134
|
+
return (checks || []).map((c) => {
|
|
135
|
+
const r = RU[c.id];
|
|
136
|
+
if (!r) return c;
|
|
137
|
+
const out = { ...c, label: r.label || c.label };
|
|
138
|
+
try { if (r.value) out.value = r.value(c.value ?? ''); } catch { /* оставляем исходный текст */ }
|
|
139
|
+
try { if (r.comment) out.comment = r.comment(c.comment ?? ''); } catch { /* оставляем исходный текст */ }
|
|
140
|
+
return out;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Русские названия шести областей. */
|
|
145
|
+
export const AREAS_RU = {
|
|
146
|
+
'SEO, technical': 'SEO, техническая часть',
|
|
147
|
+
'SEO, content and structure': 'SEO, контент и структура',
|
|
148
|
+
'AEO, answers and snippets': 'AEO, ответы и сниппеты',
|
|
149
|
+
'GEO, visibility in AI systems': 'GEO, видимость в системах ИИ',
|
|
150
|
+
'Off-page and trust': 'Ссылки и доверие',
|
|
151
|
+
'Conversion and UX': 'Конверсия и удобство',
|
|
152
|
+
};
|
package/src/index.mjs
CHANGED
package/src/render.mjs
CHANGED
|
@@ -8,11 +8,63 @@ import { spawnSync } from 'node:child_process';
|
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { computeScores } from './collect.mjs';
|
|
11
|
+
import { localiseBasisNote, AREAS_RU } from './i18n.mjs';
|
|
11
12
|
|
|
12
13
|
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
13
14
|
const isPlaceholder = (s) => typeof s === 'string' && /\{\{[^}]*\}\}/.test(s);
|
|
14
15
|
const t = (s) => (isPlaceholder(s) ? `<mark class="todo">${esc(s)}</mark>` : esc(s));
|
|
15
|
-
|
|
16
|
+
|
|
17
|
+
/** Подписи отчёта. Язык берётся из audit.meta.lang, по умолчанию английский. */
|
|
18
|
+
const LABELS = {
|
|
19
|
+
en: {
|
|
20
|
+
eyebrowAudit: 'Digital marketing audit', coverTitle: 'SEO, AEO and GEO<br>audit report',
|
|
21
|
+
auditSubject: 'Audit subject', reportDate: 'Report date', auditType: 'Audit type', preparedBy: 'Prepared by',
|
|
22
|
+
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.`,
|
|
23
|
+
secSummary: 'Executive summary', summaryEyebrow: '01 · Summary', keyTakeaway: 'Key takeaway:', threePriorities: 'Three priorities',
|
|
24
|
+
scorecardFoot: '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 section 04 onwards.',
|
|
25
|
+
notMeasured: 'not measured',
|
|
26
|
+
secOverview: 'Site overview', overviewEyebrow: '02 · Overview', whatSiteIs: 'What the site is', parameter: 'Parameter', value: 'Value', pagesSampled: 'Pages sampled', colUrl: 'URL', colTitle: 'Title', colWords: 'Words',
|
|
27
|
+
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.',
|
|
28
|
+
secTechnical: 'Technical SEO', technicalEyebrow: '04 · Technical', technicalTitle: 'Technical and on-page checklist', colCheck: 'Check', colStatus: 'Status', colFinding: 'Finding',
|
|
29
|
+
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',
|
|
30
|
+
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',
|
|
31
|
+
secOffpage: 'Off-page, conversion, limitations', offpageEyebrow: '07 · Off-page', offpageTitle: 'Off-page and trust', conversionEyebrow: '08 · Conversion', conversionTitle: 'Conversion and UX', colElement: 'Element', limitationsEyebrow: '09 · Limitations', sourcesTitle: 'Where the data comes from', colSource: 'Source', colAccess: 'Access', colWhatRead: 'What was read',
|
|
32
|
+
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 numbers.',
|
|
33
|
+
limitations: 'Limitations',
|
|
34
|
+
secRoadmap: 'Roadmap', roadmapEyebrow: '10 · Roadmap', roadmapTitle: 'What to do, in order', keyMessage: 'Key message:',
|
|
35
|
+
runningHead: 'SEO, AEO and GEO audit',
|
|
36
|
+
statusOk: '✓ OK', statusWarn: '△ Partial', statusBad: '✗ Problem', statusNa: '· Note',
|
|
37
|
+
areas: null,
|
|
38
|
+
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'}` : ''}`,
|
|
39
|
+
notMeasuredReason: { 'Off-page and trust': 'no link or mention data was collected', 'Conversion and UX': 'no conversion signals were collected' },
|
|
40
|
+
},
|
|
41
|
+
ru: {
|
|
42
|
+
eyebrowAudit: 'Аудит цифрового маркетинга', coverTitle: 'Аудит сайта:<br>SEO, AEO и GEO',
|
|
43
|
+
auditSubject: 'Что проверяли', reportDate: 'Дата отчёта', auditType: 'Тип аудита', preparedBy: 'Кто готовил',
|
|
44
|
+
collectedBy: (tool, date) => `Публичные сигналы собраны инструментом ${tool} ${date}. Любой статус в этом отчёте можно перепроверить по сайту в том виде, в каком он был в этот день.`,
|
|
45
|
+
secSummary: 'Главное', summaryEyebrow: '01 · Итог', keyTakeaway: 'Главный вывод:', threePriorities: 'Три приоритета',
|
|
46
|
+
scorecardFoot: 'Каждая оценка считает проверки из этого же отчёта: пройденная проверка это балл, спорная половина балла, проваленная ноль. Область с пометкой <strong>не измерялось</strong> это область, которую аудит не проверяет, и она никогда не оценивается на глаз. Любую цифру отсюда можно пересчитать по разделам начиная с четвёртого.',
|
|
47
|
+
notMeasured: 'не измерялось',
|
|
48
|
+
secOverview: 'О сайте', overviewEyebrow: '02 · Обзор', whatSiteIs: 'Что это за сайт', parameter: 'Параметр', value: 'Значение', pagesSampled: 'Проверенные страницы', colUrl: 'Адрес', colTitle: 'Заголовок', colWords: 'Слов',
|
|
49
|
+
secCritical: 'Критичное', criticalEyebrow: '03 · Срочно', criticalTitle: 'Что чинить первым', whatItCosts: 'Чем это грозит', theFix: 'Как чинится', noBody: 'У этой проблемы нет описания в файле аудита.', noCritical: 'Критичных дефектов в публичных сигналах не найдено.',
|
|
50
|
+
secTechnical: 'Техническое SEO', technicalEyebrow: '04 · Техника', technicalTitle: 'Техническая проверка и страницы', colCheck: 'Проверка', colStatus: 'Статус', colFinding: 'Что нашли',
|
|
51
|
+
secContent: 'Контент и страницы', contentEyebrow: '05 · Контент', contentTitle: 'Контент и внутренняя оптимизация', strengths: 'Сильные стороны', weaknesses: 'Слабые места', gaps: 'Структурные дыры: страниц, которых пока нет',
|
|
52
|
+
secAeo: 'AEO и GEO', aeoEyebrow: '06 · AEO / GEO', aeoTitle: 'Ответные системы: AEO', aeoLead: 'Может ли ответ с вашего сайта попасть в блок «Люди также спрашивают», в ответы ИИ, в ChatGPT и Perplexity.', aeoWorks: 'Что уже работает', aeoBlocks: 'Что мешает', recommendation: 'Рекомендация:', geoTitle: 'Генеративные системы: GEO', colSignal: 'Сигнал', colDetail: 'Подробности',
|
|
53
|
+
secOffpage: 'Ссылки, конверсия, ограничения', offpageEyebrow: '07 · Ссылки', offpageTitle: 'Ссылки и доверие', conversionEyebrow: '08 · Конверсия', conversionTitle: 'Конверсия и удобство', colElement: 'Элемент', limitationsEyebrow: '09 · Ограничения', sourcesTitle: 'Откуда взяты данные', colSource: 'Источник', colAccess: 'Доступ', colWhatRead: 'Что прочитали',
|
|
54
|
+
sourcesFoot: 'Каждая оценка в этом отчёте посчитана из источников с пометкой «бесплатно». Ничего платного ни в одну оценку не входит, поэтому вы можете повторить этот аудит сами и получить те же цифры.',
|
|
55
|
+
limitations: 'Ограничения',
|
|
56
|
+
secRoadmap: 'План работ', roadmapEyebrow: '10 · План', roadmapTitle: 'Что делать и в каком порядке', keyMessage: 'Главное сообщение:',
|
|
57
|
+
runningHead: 'Аудит SEO, AEO и GEO',
|
|
58
|
+
statusOk: '✓ Норма', statusWarn: '△ Частично', statusBad: '✗ Проблема', statusNa: '· Заметка',
|
|
59
|
+
areas: AREAS_RU,
|
|
60
|
+
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'}` : ''}`),
|
|
61
|
+
notMeasuredReason: { 'Off-page and trust': 'данные по ссылкам и упоминаниям не собирались', 'Conversion and UX': 'сигналы конверсии не собирались' },
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const STATUS_KEYS = { ok: 'statusOk', warn: 'statusWarn', bad: 'statusBad', na: 'statusNa' };
|
|
66
|
+
const STATUS_CLASS = { ok: 'status-ok', warn: 'status-warn', bad: 'status-bad', na: 'status-na' };
|
|
67
|
+
const STATUS_UNUSED = { ok: ['✓ OK', 'status-ok'], warn: ['△ Partial', 'status-warn'], bad: ['✗ Problem', 'status-bad'], na: ['· Note', 'status-na'] };
|
|
16
68
|
const scoreClass = (n) => (n === null || n === undefined ? '' : n <= 3 ? 'low' : n <= 6 ? 'mid' : 'ok');
|
|
17
69
|
|
|
18
70
|
export function checkNarrative(audit) {
|
|
@@ -87,47 +139,51 @@ function css() {
|
|
|
87
139
|
`;
|
|
88
140
|
}
|
|
89
141
|
|
|
90
|
-
const header = (audit, section) => `<div class="page-header"><strong>${esc(audit.client.name)}</strong><span
|
|
142
|
+
const header = (audit, section, L) => `<div class="page-header"><strong>${esc(audit.client.name)}</strong><span>${esc(L.runningHead)} · ${esc(section)}</span></div>`;
|
|
91
143
|
const footer = (audit, n) => `<div class="page-footer"><span>${esc(audit.client.preparedBy || 'OperStack')} · ${esc(audit.client.reportDate)}</span><span>${n}</span></div>`;
|
|
92
144
|
const list = (items) => `<ul>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ul>`;
|
|
93
145
|
const olist = (items) => `<ol>${(items || []).map((i) => `<li>${t(i)}</li>`).join('')}</ol>`;
|
|
94
|
-
const statusCell = (s) =>
|
|
146
|
+
const statusCell = (s, L) => `<td class="${STATUS_CLASS[s] || STATUS_CLASS.na}">${L[STATUS_KEYS[s] || 'statusNa']}</td>`;
|
|
95
147
|
|
|
96
148
|
export function toHtml(audit) {
|
|
97
149
|
const a = audit;
|
|
150
|
+
const L = LABELS[a.meta?.lang === 'ru' ? 'ru' : 'en'];
|
|
98
151
|
const groups = [['technical', 'Technical'], ['onpage', 'On-page'], ['content', 'Content'], ['aeo', 'Answer engines'], ['geo', 'Generative engines'], ['offpage', 'Off-page and trust'], ['conversion', 'Conversion'], ['overview', 'Overview']];
|
|
99
152
|
const checksBy = (g) => (a.checks || []).filter((c) => c.group === g);
|
|
100
153
|
const { scores: measured, scoreBasis } = computeScores(a.checks || []);
|
|
101
154
|
const scoreCards = Object.entries(measured).map(([label, n]) => {
|
|
102
155
|
const basis = scoreBasis[label] || {};
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
156
|
+
const shown = (L.areas && L.areas[label]) || label;
|
|
157
|
+
const num = n === null ? `<span class="score-num na">${L.notMeasured}</span>` : `<span class="score-num ${scoreClass(n)}">${n}/10</span>`;
|
|
158
|
+
const note = n === null
|
|
159
|
+
? (L.notMeasuredReason && L.notMeasuredReason[label]) || String(basis.note || '').replace(/^not measured:\s*/, '')
|
|
160
|
+
: (basis.counted ? L.basisNote(basis) : basis.note || '');
|
|
161
|
+
return `<div class="score"><div class="label">${esc(shown)}</div><div class="score-row">${num}<span class="score-note">${esc(note)}</span></div></div>`;
|
|
106
162
|
}).join('');
|
|
107
163
|
let n = 1;
|
|
108
164
|
const pages = [];
|
|
109
|
-
pages.push(`<div class="page"><div class="cover"><div><div class="cover-top"><div class="eyebrow"
|
|
110
|
-
<dl class="cover-meta"><div><dt
|
|
111
|
-
<div class="cover-foot"
|
|
165
|
+
pages.push(`<div class="page"><div class="cover"><div><div class="cover-top"><div class="eyebrow">${L.eyebrowAudit}</div><h1>${L.coverTitle}</h1><p class="cover-sub">${t(a.client.subject)}</p><span class="cover-url">${esc(a.meta.host)}</span></div>
|
|
166
|
+
<dl class="cover-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(a.client.preparedBy || 'OperStack')}</dd></div></dl></div>
|
|
167
|
+
<div class="cover-foot">${esc(L.collectedBy(a.meta.tool, (a.meta.collectedAt || '').slice(0, 10)))}</div></div></div>`);
|
|
112
168
|
n++;
|
|
113
|
-
pages.push(`<div class="page">${header(a,
|
|
114
|
-
pages.push(`<div class="page">${header(a,
|
|
169
|
+
pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p><div class="scorecard">${scoreCards}</div><p class="scorecard-foot">${L.scorecardFoot}</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++)}</div>`);
|
|
170
|
+
pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${L.overviewEyebrow}</div><h2>${L.whatSiteIs}</h2><table><tr><th>${L.parameter}</th><th>${L.value}</th></tr>${(a.overview.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><p>${t(a.overview.note)}</p><h3>${L.pagesSampled}</h3><table><tr><th>${L.colUrl}</th><th>${L.colTitle}</th><th>${L.colWords}</th><th>H1</th><th>Alt</th></tr>${(a.sample || []).filter((p) => p.title !== undefined).slice(0, 14).map((p) => `<tr><td><code>${esc(new URL(p.url).pathname)}</code></td><td>${esc(p.title)}</td><td>${p.words}</td><td>${p.h1Count}</td><td>${p.images ? `${p.images - p.imagesNoAlt}/${p.images}` : '·'}</td></tr>`).join('')}</table>${footer(a, n++)}</div>`);
|
|
115
171
|
const cardBody = (c) => [
|
|
116
172
|
c.text ? `<p>${t(c.text)}</p>` : '',
|
|
117
|
-
c.cost ? `<p><span class="card-tag"
|
|
118
|
-
c.fix ? `<p><span class="card-tag"
|
|
119
|
-
].join('') || '<p class="card-empty"
|
|
120
|
-
const cards = (a.critical || []).map((c, i) => `<div class="card ${c.level === 'warn' ? 'warn' : ''}"><h4>${i + 1}. ${t(c.title)}</h4>${cardBody(c)}</div>`).join('') ||
|
|
121
|
-
pages.push(`<div class="page">${header(a,
|
|
122
|
-
const techRows = [...checksBy('technical'), ...checksBy('onpage')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status)}<td>${esc(c.value)}${c.comment ? `<br><span style="color:var(--muted)">${esc(c.comment)}</span>` : ''}</td></tr>`).join('');
|
|
123
|
-
pages.push(`<div class="page">${header(a,
|
|
124
|
-
pages.push(`<div class="page">${header(a,
|
|
125
|
-
const geoRows = [...checksBy('aeo'), ...checksBy('geo')].map((c) => `<tr><td>${esc(c.label)}</td>${statusCell(c.status)}<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('');
|
|
126
|
-
pages.push(`<div class="page">${header(a,
|
|
127
|
-
const checkTable = (g) => (checksBy(g).length ? `<table><tr><th
|
|
128
|
-
pages.push(`<div class="page">${header(a,
|
|
173
|
+
c.cost ? `<p><span class="card-tag">${L.whatItCosts}</span>${t(c.cost)}</p>` : '',
|
|
174
|
+
c.fix ? `<p><span class="card-tag">${L.theFix}</span>${t(c.fix)}</p>` : '',
|
|
175
|
+
].join('') || '<p class="card-empty">${L.noBody}</p>';
|
|
176
|
+
const cards = (a.critical || []).map((c, i) => `<div class="card ${c.level === 'warn' ? 'warn' : ''}"><h4>${i + 1}. ${t(c.title)}</h4>${cardBody(c)}</div>`).join('') || `<p>${L.noCritical}</p>`;
|
|
177
|
+
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++)}</div>`);
|
|
178
|
+
const techRows = [...checksBy('technical'), ...checksBy('onpage')].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('');
|
|
179
|
+
pages.push(`<div class="page">${header(a, L.secTechnical, L)}<div class="eyebrow">${L.technicalEyebrow}</div><h2>${L.technicalTitle}</h2><table><tr><th>${L.colCheck}</th><th>${L.colStatus}</th><th>${L.colFinding}</th></tr>${techRows}</table>${footer(a, n++)}</div>`);
|
|
180
|
+
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++)}</div>`);
|
|
181
|
+
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('');
|
|
182
|
+
pages.push(`<div class="page">${header(a, L.secAeo, L)}<div class="eyebrow">${L.aeoEyebrow}</div><h2>${L.aeoTitle}</h2><p class="lead">Whether the site's answers can be lifted into "People also ask", AI overviews, ChatGPT and Perplexity.</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><h2>${L.geoTitle}</h2><table><tr><th>${L.colSignal}</th><th>${L.colStatus}</th><th>${L.colDetail}</th></tr>${geoRows}</table><div class="callout"><p>${t(a.geo.callout)}</p></div>${footer(a, n++)}</div>`);
|
|
183
|
+
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>` : '');
|
|
184
|
+
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><div class="eyebrow" style="margin-top:8mm">${L.conversionEyebrow}</div><h2>${L.conversionTitle}</h2>${checkTable('conversion')}<table><tr><th>${L.colElement}</th><th>${L.colStatus}</th></tr>${(a.conversion.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><div class="eyebrow" style="margin-top:8mm">${L.limitationsEyebrow}</div><h2>${L.sourcesTitle}</h2>${(a.sources || []).length ? `<table><tr><th>${L.colSource}</th><th>${L.colAccess}</th><th>${L.colWhatRead}</th></tr>${a.sources.map((s) => `<tr><td>${t(s.name)}</td><td>${t(s.access)}</td><td>${t(s.detail)}</td></tr>`).join('')}</table><p class="scorecard-foot">${L.sourcesFoot}</p>` : ''}<h3>${L.limitations}</h3>${list(a.limitations)}${footer(a, n++)}</div>`);
|
|
129
185
|
const phases = (a.roadmap || []).map((p) => `<div class="phase"><div class="phase-head"><span class="badge">${esc(p.badge)}</span><span class="phase-title">${t(p.title)}</span></div>${olist(p.items)}</div>`).join('');
|
|
130
|
-
pages.push(`<div class="page">${header(a,
|
|
186
|
+
pages.push(`<div class="page">${header(a, L.secRoadmap, L)}<div class="eyebrow">${L.roadmapEyebrow}</div><h2>${L.roadmapTitle}</h2>${phases}<div class="verdict"><p><strong>${L.keyMessage}</strong> ${t(a.closing)}</p></div>${footer(a, n++)}</div>`);
|
|
131
187
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(a.client.name)}: SEO, AEO and GEO audit</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()}</style></head><body>${pages.join('\n')}</body></html>`;
|
|
132
188
|
}
|
|
133
189
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Перевод проверок: покрытие, отсутствие английских хвостов, целость чисел. */
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { localiseChecks, untranslated, localiseBasisNote } from './i18n.mjs';
|
|
5
|
+
|
|
6
|
+
let bad = 0;
|
|
7
|
+
const ok = (n, c) => { if (!c) { bad++; console.error(`FAIL ${n}`); } else console.log(`ok ${n}`); };
|
|
8
|
+
const is = (n, a, b) => { const g = JSON.stringify(a) === JSON.stringify(b); if (!g) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
|
|
9
|
+
|
|
10
|
+
const files = process.argv.slice(2);
|
|
11
|
+
if (!files.length) { console.error('нужен хотя бы один audit.json для проверки покрытия'); process.exit(2); }
|
|
12
|
+
const all = [];
|
|
13
|
+
for (const f of files) all.push(...JSON.parse(readFileSync(f, 'utf8')).checks);
|
|
14
|
+
|
|
15
|
+
is('перевод есть для всех собранных проверок', untranslated(all), []);
|
|
16
|
+
|
|
17
|
+
const ru = localiseChecks(all, 'ru');
|
|
18
|
+
is('количество проверок не изменилось', ru.length, all.length);
|
|
19
|
+
ok('статусы не тронуты', ru.every((c, i) => c.status === all[i].status));
|
|
20
|
+
ok('идентификаторы не тронуты', ru.every((c, i) => c.id === all[i].id));
|
|
21
|
+
|
|
22
|
+
// в русском тексте не должно остаться английских слов, кроме технических имён
|
|
23
|
+
const TECH = /https?|robots\.txt|llms\.txt|sitemap|xmlrpc|HTTP|HTTPS|H1|H2|og:|twitter:|JSON-LD|FAQPage|Organization|WebSite|Article|LocalBusiness|viewport|width|initial-scale|device-width|user-scalable|maximum-scale|Disallow|User-agent|gptbot|google-extended|ccbot|anthropic-ai|claudebot|applebot-extended|facebookbot|meta-externalagent|bytespider|oai-searchbot|chatgpt-user|perplexitybot|claude-searchbot|duckassistbot|applebot|sameAs|com|ru|org|io|me|net|dev|github|npmjs|apify|producthunt|wikidata|moregroup|estate|Content|Type|Cache|Control|OperStack|SEO|AEO|GEO|AI|XML|CMS|PDF|URL/gi;
|
|
24
|
+
const leftovers = [];
|
|
25
|
+
for (const c of ru) {
|
|
26
|
+
for (const field of ['label', 'value', 'comment']) {
|
|
27
|
+
const t = String(c[field] || '')
|
|
28
|
+
.replace(/\/[A-Za-z0-9_\-/.]+\/?/g, ' ') // пути страниц клиента
|
|
29
|
+
.replace(/\b[a-z-]+:[a-z_:]+/gi, ' ') // og:title, twitter:summary_large_image
|
|
30
|
+
.replace(/\bhreflang\b/gi, ' ') // имя атрибута
|
|
31
|
+
.replace(TECH, '').replace(/[^A-Za-z]/g, ' ');
|
|
32
|
+
const words = t.split(/\s+/).filter((w) => w.length > 3);
|
|
33
|
+
if (words.length) leftovers.push(`${c.id}.${field}: ${words.join(' ')}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
is('английских слов в русском тексте не осталось', leftovers, []);
|
|
37
|
+
|
|
38
|
+
// числа не потерялись
|
|
39
|
+
const digits = (s) => (String(s).match(/\d+/g) || []).join(',');
|
|
40
|
+
const lostNumbers = ru.filter((c, i) => {
|
|
41
|
+
const before = digits(all[i].value);
|
|
42
|
+
const after = digits(c.value);
|
|
43
|
+
return before && after && before.split(',').some((n) => !after.split(',').includes(n));
|
|
44
|
+
}).map((c) => `${c.id}: было ${digits(all[all.findIndex((x) => x.id === c.id)].value)}, стало ${digits(c.value)}`);
|
|
45
|
+
is('числа в тексте сохранились', lostNumbers, []);
|
|
46
|
+
|
|
47
|
+
is('подпись под оценкой переведена', localiseBasisNote('10 of 11 checks pass, 1 needs attention'), '10 из 11 проверок пройдено, 1 спорная');
|
|
48
|
+
is('склонение на пяти', localiseBasisNote('3 of 5 checks pass, 2 need attention'), '3 из 5 проверок пройдено, 2 спорные');
|
|
49
|
+
is('провал тоже склоняется', localiseBasisNote('1 of 3 checks pass, 1 needs attention, 1 fails'), '1 из 3 проверок пройдено, 1 спорная, 1 провалена');
|
|
50
|
+
is('неизмеренное переводится', localiseBasisNote('not measured: no link data was collected'), 'не измерялось: no link data was collected');
|
|
51
|
+
|
|
52
|
+
is('английский язык ничего не меняет', localiseChecks(all, 'en'), all);
|
|
53
|
+
|
|
54
|
+
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
55
|
+
console.log(`\nперевод проверен на ${all.length} проверках, всё чисто`);
|