@operstack/audit 0.4.0 → 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 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.4.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
@@ -1,2 +1,3 @@
1
- export { collect } from './collect.mjs';
1
+ export { collect, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
2
2
  export { render, checkNarrative } from './render.mjs';
3
+ export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18n.mjs';
package/src/render.mjs CHANGED
@@ -8,6 +8,7 @@ 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
13
14
  const isPlaceholder = (s) => typeof s === 'string' && /\{\{[^}]*\}\}/.test(s);
@@ -55,21 +56,8 @@ const LABELS = {
55
56
  secRoadmap: 'План работ', roadmapEyebrow: '10 · План', roadmapTitle: 'Что делать и в каком порядке', keyMessage: 'Главное сообщение:',
56
57
  runningHead: 'Аудит SEO, AEO и GEO',
57
58
  statusOk: '✓ Норма', statusWarn: '△ Частично', statusBad: '✗ Проблема', statusNa: '· Заметка',
58
- areas: {
59
- 'SEO, technical': 'SEO, техническая часть',
60
- 'SEO, content and structure': 'SEO, контент и структура',
61
- 'AEO, answers and snippets': 'AEO, ответы и сниппеты',
62
- 'GEO, visibility in AI systems': 'GEO, видимость в системах ИИ',
63
- 'Off-page and trust': 'Ссылки и доверие',
64
- 'Conversion and UX': 'Конверсия и удобство',
65
- },
66
- basisNote: (b) => {
67
- const w = (n, one, few, many) => { const d = n % 10, dd = n % 100; return n + ' ' + (d === 1 && dd !== 11 ? one : d >= 2 && d <= 4 && (dd < 10 || dd >= 20) ? few : many); };
68
- const parts = [`${b.ok} из ${w(b.counted, 'проверки', 'проверок', 'проверок')} пройдено`];
69
- if (b.warn) parts.push(`${w(b.warn, 'спорная', 'спорные', 'спорных')}`);
70
- if (b.bad) parts.push(`${w(b.bad, 'провалена', 'провалены', 'провалено')}`);
71
- return parts.join(', ');
72
- },
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'}` : ''}`),
73
61
  notMeasuredReason: { 'Off-page and trust': 'данные по ссылкам и упоминаниям не собирались', 'Conversion and UX': 'сигналы конверсии не собирались' },
74
62
  },
75
63
  };
@@ -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} проверках, всё чисто`);